From ba5365d4afc73f8923cc166458fd9ee3c22ed22c Mon Sep 17 00:00:00 2001 From: Aditya Date: Wed, 18 Mar 2026 17:54:10 +0530 Subject: [PATCH] Add all CBS custom modules: KYC, Sanctions, RBI Feed, OpenProject, Mobile Lookup, Charges, Avro schemas Includes all prior work not yet committed on this branch: - KYC module (LincPay eKYC: PAN, Aadhaar OTP, GSTIN verification) - Sanctions screening (UN Security Council consolidated list) - RBI Feed integration with scheduled jobs - OpenProject API integration - Mobile lookup service - Charge module enhancements (current account support) - Client family member nominee fields - Avro schema updates - Liquibase migrations 0216-0224 - Postman collections for OpenProject, RBI Feeds, Sanctions - SQL seed scripts Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenProject_Postman_Collection.json | 273 +++++++++ RBI_Feeds_Postman_Collection.json | 276 ++++++++++ Sanctions_Screening_Postman_Collection.json | 375 +++++++++++++ .../charge/domain/ChargeAppliesTo.java | 12 +- .../charge/domain/ChargeCalculationType.java | 4 + ...initionCommandFromApiJsonDeserializer.java | 32 ++ .../ChargeDropdownReadPlatformService.java | 4 + ...ChargeDropdownReadPlatformServiceImpl.java | 14 + .../charge/service/ChargeEnumerations.java | 4 + .../infrastructure/jobs/service/JobName.java | 1 + .../data/OpenProjectProjectData.java | 37 ++ .../data/OpenProjectStatusSummaryData.java | 35 ++ .../data/OpenProjectWorkPackageData.java | 42 ++ .../rbifeed/data/RbiFeedItemData.java | 51 ++ .../rbifeed/domain/RbiFeedItem.java | 67 +++ .../rbifeed/domain/RbiFeedItemRepository.java | 36 ++ .../portfolio/charge/data/ChargeData.java | 4 + .../charge/domain/ChargeTimeType.java | 7 + .../client/api/ClientApiConstants.java | 1 + .../data/ClientFamilyMemberRequest.java | 4 + .../client/data/ClientFamilyMembersData.java | 7 + .../portfolio/client/domain/ClientKyc.java | 380 +++++++++++++ .../client/domain/ClientKycRepository.java | 38 ++ .../domain/ClientKycRepositoryWrapper.java | 57 ++ .../client/domain/ClientKycStatus.java | 88 +++ .../client/domain/ClientKycType.java | 67 +++ .../client/domain/KycRiskCategory.java | 71 +++ .../ClientKycAlreadyExistsException.java | 31 ++ ...entKycInvalidStateTransitionException.java | 30 + .../exception/ClientKycNotFoundException.java | 33 ++ .../mobilelookup/domain/MobileLookupLog.java | 82 +++ .../domain/MobileLookupLogRepository.java | 38 ++ .../sanctions/domain/SanctionsAlias.java | 60 ++ .../sanctions/domain/SanctionsEntry.java | 157 ++++++ .../domain/SanctionsEntryRepository.java | 43 ++ .../domain/SanctionsScreeningLog.java | 90 +++ .../SanctionsScreeningLogRepository.java | 42 ++ .../api/OpenProjectApiResource.java | 187 +++++++ .../service/OpenProjectProperties.java | 36 ++ .../service/OpenProjectService.java | 288 ++++++++++ .../rbifeed/api/RbiFeedApiResource.java | 152 +++++ .../fetchrbifeeds/FetchRbiFeedsConfig.java | 52 ++ .../fetchrbifeeds/FetchRbiFeedsTasklet.java | 44 ++ .../rbifeed/service/RbiFeedFetchService.java | 269 +++++++++ .../service/RbiFeedReadPlatformService.java | 31 ++ .../RbiFeedReadPlatformServiceImpl.java | 96 ++++ .../ChargeReadPlatformServiceImpl.java | 6 + .../client/api/ClientKycApiResource.java | 267 +++++++++ .../client/command/ClientKycCommand.java | 220 ++++++++ .../data/ClientApiCollectionConstants.java | 2 +- .../client/data/ClientKycApiConstants.java | 89 +++ .../portfolio/client/data/ClientKycData.java | 72 +++ .../client/data/ClientKycRequest.java | 83 +++ .../client/domain/ClientFamilyMembers.java | 45 +- ...rateAadhaarOtpClientKycCommandHandler.java | 52 ++ .../InitiateClientKycCommandHandler.java | 42 ++ .../RejectClientKycCommandHandler.java | 42 ++ .../UpdateClientKycCommandHandler.java | 42 ++ .../VerifyAadhaarClientKycCommandHandler.java | 51 ++ .../VerifyClientKycCommandHandler.java | 42 ++ .../VerifyGstinClientKycCommandHandler.java | 50 ++ .../VerifyPanClientKycCommandHandler.java | 51 ++ .../client/kyc/KycVerificationResult.java | 63 +++ .../client/kyc/KycVerificationService.java | 77 +++ .../kyc/KycVerificationServiceImpl.java | 70 +++ .../LincPayKycVerificationServiceImpl.java | 336 +++++++++++ ...yMemberCommandFromApiJsonDeserializer.java | 36 +- ...ientKycCommandFromApiJsonDeserializer.java | 83 +++ ...tFamilyMembersReadPlatformServiceImpl.java | 10 +- ...FamilyMembersWritePlatformServiceImpl.java | 40 +- .../service/ClientKycReadPlatformService.java | 34 ++ .../ClientKycReadPlatformServiceImpl.java | 86 +++ .../ClientKycWritePlatformService.java | 74 +++ ...WritePlatformServiceJpaRepositoryImpl.java | 460 ++++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 11 + .../api/MobileLookupApiResource.java | 116 ++++ .../data/MobileLookupDetailData.java | 42 ++ .../data/MobileLookupLogData.java | 51 ++ .../mobilelookup/data/MobileLookupResult.java | 81 +++ .../service/MobileLookupService.java | 36 ++ .../service/MobileLookupServiceImpl.java | 219 ++++++++ .../api/SanctionsScreeningApiResource.java | 194 +++++++ .../sanctions/data/SanctionsMatchData.java | 76 +++ .../data/SanctionsScreeningLogData.java | 85 +++ .../data/SanctionsScreeningResult.java | 66 +++ .../service/SanctionsScreeningService.java | 62 +++ .../SanctionsScreeningServiceImpl.java | 148 +++++ .../service/SanctionsXmlLoaderService.java | 276 ++++++++++ .../src/main/resources/application.properties | 36 +- .../parts/0216_add_client_kyc_tables.xml | 336 +++++++++++ ...7_add_nominee_fields_to_family_members.xml | 38 ++ .../parts/0218_add_pan_response_fields.xml | 43 ++ .../tenant/parts/0219_add_rbi_feed_tables.xml | 86 +++ .../0220_add_openproject_permissions.xml | 47 ++ ...0221_add_openproject_wp_id_to_rbi_feed.xml | 40 ++ .../0222_add_sanctions_screening_tables.xml | 276 ++++++++++ .../0223_add_sanctions_screening_log.xml | 137 +++++ .../parts/0224_add_mobile_lookup_log.xml | 169 ++++++ .../src/main/resources/logback-spring.xml | 16 + sql/kyc/create_kyc_tables_mysql_mariadb.sql | 139 +++++ sql/kyc/create_kyc_tables_postgresql.sql | 155 ++++++ sql/seed_sample_data.sql | 521 ++++++++++++++++++ 102 files changed, 9757 insertions(+), 18 deletions(-) create mode 100644 OpenProject_Postman_Collection.json create mode 100644 RBI_Feeds_Postman_Collection.json create mode 100644 Sanctions_Screening_Postman_Collection.json create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectProjectData.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectStatusSummaryData.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectWorkPackageData.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/data/RbiFeedItemData.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItem.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItemRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepositoryWrapper.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycStatus.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycType.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycRiskCategory.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycAlreadyExistsException.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycInvalidStateTransitionException.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycNotFoundException.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLogRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsAlias.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntry.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntryRepository.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java create mode 100644 fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/api/OpenProjectApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectProperties.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/api/RbiFeedApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/GenerateAadhaarOtpClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/InitiateClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/RejectClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/UpdateClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyAadhaarClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyGstinClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyPanClientKycCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupDetailData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupResult.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsMatchData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0216_add_client_kyc_tables.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0217_add_nominee_fields_to_family_members.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0218_add_pan_response_fields.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0219_add_rbi_feed_tables.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0220_add_openproject_permissions.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0221_add_openproject_wp_id_to_rbi_feed.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_sanctions_screening_tables.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0223_add_sanctions_screening_log.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0224_add_mobile_lookup_log.xml create mode 100644 sql/kyc/create_kyc_tables_mysql_mariadb.sql create mode 100644 sql/kyc/create_kyc_tables_postgresql.sql create mode 100644 sql/seed_sample_data.sql diff --git a/OpenProject_Postman_Collection.json b/OpenProject_Postman_Collection.json new file mode 100644 index 000000000..f3ffb0a45 --- /dev/null +++ b/OpenProject_Postman_Collection.json @@ -0,0 +1,273 @@ +{ + "info": { + "_postman_id": "op-fineract-collection", + "name": "Fineract - OpenProject Integration", + "description": "Postman collection for Fineract OpenProject integration API endpoints.\n\nBase URL: {{baseUrl}}/fineract-provider/api/v1/openproject\n\nAuthentication: Basic Auth (Fineract user credentials)", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{fineractUser}}", + "type": "string" + }, + { + "key": "password", + "value": "{{fineractPassword}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://localhost:8443", + "type": "string" + }, + { + "key": "fineractUser", + "value": "mifos", + "type": "string" + }, + { + "key": "fineractPassword", + "value": "password", + "type": "string" + }, + { + "key": "tenantId", + "value": "default", + "type": "string" + } + ], + "item": [ + { + "name": "Get Project Details", + "request": { + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/project", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "project"] + }, + "description": "Returns the configured OpenProject project details (CBSNexora)." + } + }, + { + "name": "Get Work Packages (All)", + "request": { + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/work-packages?pageSize=25&offset=1", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "work-packages"], + "query": [ + { + "key": "pageSize", + "value": "25" + }, + { + "key": "offset", + "value": "1" + } + ] + }, + "description": "Returns all work packages from the OpenProject project." + } + }, + { + "name": "Get Work Packages (Open)", + "request": { + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/work-packages?status=open&pageSize=25&offset=1", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "work-packages"], + "query": [ + { + "key": "status", + "value": "open" + }, + { + "key": "pageSize", + "value": "25" + }, + { + "key": "offset", + "value": "1" + } + ] + }, + "description": "Returns only open work packages." + } + }, + { + "name": "Get Work Packages (Closed)", + "request": { + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/work-packages?status=closed&pageSize=25&offset=1", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "work-packages"], + "query": [ + { + "key": "status", + "value": "closed" + }, + { + "key": "pageSize", + "value": "25" + }, + { + "key": "offset", + "value": "1" + } + ] + }, + "description": "Returns only closed work packages." + } + }, + { + "name": "Get Status Summary", + "request": { + "method": "GET", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/summary", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "summary"] + }, + "description": "Returns a summary of all work packages grouped by status, type, and priority." + } + }, + { + "name": "Create Work Package - Feature", + "request": { + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subject\": \"Integrate OpenProject with CBS Dashboard\",\n \"description\": \"Add OpenProject work package tracking to the CBS admin dashboard so project managers can view task progress directly from the banking system.\",\n \"type\": \"Feature\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/work-packages", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "work-packages"] + }, + "description": "Creates a new Feature work package in OpenProject.\n\nValid types: Task, Milestone, Summary task, Feature, Epic, User story, Bug, Incident, Support Ticket, Problem, Change Request, Enhancement" + } + }, + { + "name": "Create Work Package - Task", + "request": { + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subject\": \"Setup CI/CD pipeline for CBS deployment\",\n \"description\": \"Configure automated build and deployment pipeline for the Core Banking System.\",\n \"type\": \"Task\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/work-packages", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "work-packages"] + }, + "description": "Creates a new Task work package in OpenProject." + } + }, + { + "name": "Create Work Package - Bug", + "request": { + "method": "POST", + "header": [ + { + "key": "fineract-platform-tenantid", + "value": "{{tenantId}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subject\": \"Fix loan repayment calculation rounding error\",\n \"description\": \"Loan repayment amounts show minor rounding differences when calculated with different interest methods.\",\n \"type\": \"Bug\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/openproject/work-packages", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "openproject", "work-packages"] + }, + "description": "Creates a new Bug work package in OpenProject." + } + } + ] +} diff --git a/RBI_Feeds_Postman_Collection.json b/RBI_Feeds_Postman_Collection.json new file mode 100644 index 000000000..5b975cb7e --- /dev/null +++ b/RBI_Feeds_Postman_Collection.json @@ -0,0 +1,276 @@ +{ + "info": { + "_postman_id": "rbi-feeds-collection", + "name": "RBI Feeds API - Fineract CBS", + "description": "RBI RSS Feed endpoints for fetching and retrieving RBI circulars, notifications, press releases etc.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "mifos", + "type": "string" + }, + { + "key": "password", + "value": "password", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://localhost:8443/fineract-provider/api/v1", + "type": "string" + }, + { + "key": "tenantId", + "value": "default", + "type": "string" + } + ], + "item": [ + { + "name": "1. Trigger Fetch (Run First)", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/rbi-feeds/fetch", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds", "fetch"] + }, + "description": "Fetches latest RSS feeds from rbi.org.in (5 categories: PRESS_RELEASE, NOTIFICATION, SPEECH, TENDER, PUBLICATION) and stores new items in the database. Run this FIRST to populate data." + }, + "response": [] + }, + { + "name": "2. Get All Feeds", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/rbi-feeds", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds"] + }, + "description": "Retrieve all RBI feed items sorted by publication date (newest first)." + }, + "response": [] + }, + { + "name": "3. Get Feeds by Category - PRESS_RELEASE", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/rbi-feeds?category=PRESS_RELEASE", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds"], + "query": [ + { + "key": "category", + "value": "PRESS_RELEASE" + } + ] + }, + "description": "Filter feeds by PRESS_RELEASE category." + }, + "response": [] + }, + { + "name": "4. Get Feeds by Category - NOTIFICATION", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/rbi-feeds?category=NOTIFICATION", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds"], + "query": [ + { + "key": "category", + "value": "NOTIFICATION" + } + ] + }, + "description": "Filter feeds by NOTIFICATION category." + }, + "response": [] + }, + { + "name": "5. Get Feeds by Category - SPEECH", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/rbi-feeds?category=SPEECH", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds"], + "query": [ + { + "key": "category", + "value": "SPEECH" + } + ] + }, + "description": "Filter feeds by SPEECH category." + }, + "response": [] + }, + { + "name": "6. Get Feeds by Category - TENDER", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/rbi-feeds?category=TENDER", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds"], + "query": [ + { + "key": "category", + "value": "TENDER" + } + ] + }, + "description": "Filter feeds by TENDER category." + }, + "response": [] + }, + { + "name": "7. Get Feeds by Category - PUBLICATION", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/rbi-feeds?category=PUBLICATION", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds"], + "query": [ + { + "key": "category", + "value": "PUBLICATION" + } + ] + }, + "description": "Filter feeds by PUBLICATION category." + }, + "response": [] + }, + { + "name": "8. Get Single Feed by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/rbi-feeds/1", + "host": ["{{baseUrl}}"], + "path": ["rbi-feeds", "1"] + }, + "description": "Retrieve a single RBI feed item by its ID. Change the ID in the URL as needed." + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/Sanctions_Screening_Postman_Collection.json b/Sanctions_Screening_Postman_Collection.json new file mode 100644 index 000000000..e659ea62e --- /dev/null +++ b/Sanctions_Screening_Postman_Collection.json @@ -0,0 +1,375 @@ +{ + "info": { + "_postman_id": "sanctions-screening-001", + "name": "Sanctions Screening API", + "description": "API collection for UN Security Council consolidated sanctions list screening.\n\nEndpoints:\n- Screen client names against sanctions list\n- Load sanctions XML data\n- Check sanctions database status\n\nBase URL: {{baseUrl}}/fineract-provider/api/v1", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{username}}", + "type": "string" + }, + { + "key": "password", + "value": "{{password}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://localhost:8443" + }, + { + "key": "username", + "value": "mifos" + }, + { + "key": "password", + "value": "password" + }, + { + "key": "tenantId", + "value": "default" + } + ], + "item": [ + { + "name": "Sanctions Screening", + "description": "Screen client names against the UN Security Council consolidated sanctions list", + "item": [ + { + "name": "Screen Name (GET)", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/screen?name=Mohammad Ali", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "screen"], + "query": [ + { + "key": "name", + "value": "Mohammad Ali", + "description": "Name to screen against sanctions list" + } + ] + }, + "description": "Screen a client name against the UN Security Council consolidated sanctions list using GET method.\n\n**Response when match found (FLAGGED):**\n```json\n{\n \"screenedName\": \"Mohammad Ali\",\n \"matchFound\": true,\n \"status\": \"FLAGGED\",\n \"message\": \"Potential match found in UN Security Council consolidated sanctions list. Manual review required before proceeding.\",\n \"matchCount\": 2,\n \"matches\": [...]\n}\n```\n\n**Response when no match (CLEAR):**\n```json\n{\n \"screenedName\": \"John Smith\",\n \"matchFound\": false,\n \"status\": \"CLEAR\",\n \"message\": \"No match found in UN Security Council consolidated sanctions list.\",\n \"matchCount\": 0,\n \"matches\": []\n}\n```" + } + }, + { + "name": "Screen Name (POST)", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"AL-QAIDA\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/screen", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "screen"] + }, + "description": "Screen a client name against sanctions list using POST method with JSON body." + } + }, + { + "name": "Screen Name - Individual (FLAGGED example)", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"ERIC BADEGE\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/screen", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "screen"] + }, + "description": "Example: Screen a known sanctioned individual name. Should return FLAGGED status." + } + }, + { + "name": "Screen Name - Clear (no match)", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Aditya Kumar\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/screen", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "screen"] + }, + "description": "Example: Screen a name that does not exist in sanctions list. Should return CLEAR status." + } + }, + { + "name": "Screen Name - Entity (FLAGGED example)", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"TALIBAN\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/screen", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "screen"] + }, + "description": "Example: Screen a known sanctioned entity name. Should return FLAGGED status." + } + } + ] + }, + { + "name": "Sanctions Management", + "description": "Load and manage sanctions data", + "item": [ + { + "name": "Get Sanctions Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/status", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "status"] + }, + "description": "Get the count of active sanctions entries in the database.\n\n**Response:**\n```json\n{\n \"totalActiveEntries\": 1002,\n \"status\": \"ACTIVE\"\n}\n```" + } + }, + { + "name": "Load Sanctions XML", + "request": { + "method": "POST", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/xml", + "type": "text" + } + ], + "body": { + "mode": "file", + "file": { + "src": "" + } + }, + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/load", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "load"] + }, + "description": "Upload and load the UN consolidated sanctions XML file into the database.\n\nSelect the `consolidatedLegacyByPRN.xml` file in the Body > Binary section.\n\n**Response:**\n```json\n{\n \"message\": \"Sanctions data loaded successfully\",\n \"entriesLoaded\": 1002,\n \"totalActive\": 1002\n}\n```" + } + } + ] + }, + { + "name": "Screening Activity Logs", + "description": "View sanctions screening activity logs — every search is recorded", + "item": [ + { + "name": "Get All Screening Logs", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/logs", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "logs"] + }, + "description": "Get all sanctions screening activity logs (most recent first).\n\n**Response:**\n```json\n[\n {\n \"id\": 1,\n \"screenedName\": \"ERIC BADEGE\",\n \"screeningResult\": \"FLAGGED\",\n \"matchCount\": 1,\n \"matchedNames\": \"ERIC BADEGE\",\n \"screeningSource\": \"CLIENT_CREATION\",\n \"screenedOn\": \"2026-03-16T10:30:00Z\",\n \"screenedBy\": 1\n }\n]\n```" + } + }, + { + "name": "Get FLAGGED Logs Only", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/logs?result=FLAGGED", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "logs"], + "query": [ + { + "key": "result", + "value": "FLAGGED", + "description": "Filter by result: FLAGGED or CLEAR" + } + ] + }, + "description": "Get only FLAGGED screening logs — shows all searches that matched a sanctioned entry." + } + }, + { + "name": "Get CLEAR Logs Only", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/logs?result=CLEAR", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "logs"], + "query": [ + { + "key": "result", + "value": "CLEAR", + "description": "Filter by result: FLAGGED or CLEAR" + } + ] + }, + "description": "Get only CLEAR screening logs — shows all searches that did not match any sanctioned entry." + } + }, + { + "name": "Search Logs by Name", + "request": { + "method": "GET", + "header": [ + { + "key": "Fineract-Platform-TenantId", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/fineract-provider/api/v1/sanctions/logs?name=ERIC", + "host": ["{{baseUrl}}"], + "path": ["fineract-provider", "api", "v1", "sanctions", "logs"], + "query": [ + { + "key": "name", + "value": "ERIC", + "description": "Search logs by screened name (partial match)" + } + ] + }, + "description": "Search screening logs by the name that was screened (partial, case-insensitive match)." + } + } + ] + } + ] +} diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeAppliesTo.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeAppliesTo.java index 4f0defe34..1da6af24c 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeAppliesTo.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeAppliesTo.java @@ -24,7 +24,8 @@ public enum ChargeAppliesTo { LOAN(1, "chargeAppliesTo.loan"), // SAVINGS(2, "chargeAppliesTo.savings"), // CLIENT(3, "chargeAppliesTo.client"), // - SHARES(4, "chargeAppliesTo.shares"); // + SHARES(4, "chargeAppliesTo.shares"), // + CURRENT_ACCOUNT(5, "chargeAppliesTo.currentAccount"); // private final Integer value; private final String code; @@ -59,6 +60,9 @@ public enum ChargeAppliesTo { case 4: chargeAppliesToType = SHARES; break; + case 5: + chargeAppliesToType = CURRENT_ACCOUNT; + break; default: chargeAppliesToType = INVALID; break; @@ -84,8 +88,12 @@ public enum ChargeAppliesTo { return this.value.equals(SHARES.getValue()); } + public boolean isCurrentAccountCharge() { + return this.value.equals(CURRENT_ACCOUNT.getValue()); + } + public static Object[] validValues() { return new Object[] { ChargeAppliesTo.LOAN.getValue(), ChargeAppliesTo.SAVINGS.getValue(), ChargeAppliesTo.CLIENT.getValue(), - ChargeAppliesTo.SHARES.getValue() }; + ChargeAppliesTo.SHARES.getValue(), ChargeAppliesTo.CURRENT_ACCOUNT.getValue() }; } } diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java index 832722481..7d49999ae 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java @@ -53,6 +53,10 @@ public enum ChargeCalculationType { return new Integer[] { ChargeCalculationType.FLAT.getValue(), ChargeCalculationType.PERCENT_OF_AMOUNT.getValue() }; } + public static Object[] validValuesForCurrentAccount() { + return new Integer[] { ChargeCalculationType.FLAT.getValue(), ChargeCalculationType.PERCENT_OF_AMOUNT.getValue() }; + } + public static Object[] validValuesForShares() { return new Integer[] { ChargeCalculationType.FLAT.getValue(), ChargeCalculationType.PERCENT_OF_AMOUNT.getValue() }; } diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/serialization/ChargeDefinitionCommandFromApiJsonDeserializer.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/serialization/ChargeDefinitionCommandFromApiJsonDeserializer.java index 30e05c41f..112bd497e 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/serialization/ChargeDefinitionCommandFromApiJsonDeserializer.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/serialization/ChargeDefinitionCommandFromApiJsonDeserializer.java @@ -255,6 +255,38 @@ public final class ChargeDefinitionCommandFromApiJsonDeserializer { baseDataValidator.reset().parameter(CHARGE_CALCULATION_TYPE).value(chargeCalculationType) .isOneOfTheseValues(ChargeCalculationType.validValuesForShareAccountActivation()); } + } else if (appliesTo.isCurrentAccountCharge()) { + // current account applicable validation — same rules as savings + final Integer chargeTimeType = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(CHARGE_TIME_TYPE, element); + baseDataValidator.reset().parameter(CHARGE_TIME_TYPE).value(chargeTimeType).notNull(); + if (chargeTimeType != null) { + baseDataValidator.reset().parameter(CHARGE_TIME_TYPE).value(chargeTimeType) + .isOneOfTheseValues(ChargeTimeType.validCurrentAccountValues()); + } + + final ChargeTimeType ctt = ChargeTimeType.fromInt(chargeTimeType); + + if (ctt.isWeeklyFee()) { + final String monthDay = this.fromApiJsonHelper.extractStringNamed(FEE_ON_MONTH_DAY, element); + baseDataValidator.reset().parameter(FEE_ON_MONTH_DAY).value(monthDay).mustBeBlankWhenParameterProvidedIs(CHARGE_TIME_TYPE, + chargeTimeType); + } + + if (ctt.isMonthlyFee()) { + final MonthDay monthDay = this.fromApiJsonHelper.extractMonthDayNamed(FEE_ON_MONTH_DAY, element); + baseDataValidator.reset().parameter(FEE_ON_MONTH_DAY).value(monthDay).notNull(); + baseDataValidator.reset().parameter(FEE_INTERVAL).value(feeInterval).notNull().inMinMaxRange(1, 12); + } + + if (ctt.isAnnualFee()) { + final MonthDay monthDay = this.fromApiJsonHelper.extractMonthDayNamed(FEE_ON_MONTH_DAY, element); + baseDataValidator.reset().parameter(FEE_ON_MONTH_DAY).value(monthDay).notNull(); + } + + if (chargeCalculationType != null) { + baseDataValidator.reset().parameter(CHARGE_CALCULATION_TYPE).value(chargeCalculationType) + .isOneOfTheseValues(ChargeCalculationType.validValuesForCurrentAccount()); + } } final String name = this.fromApiJsonHelper.extractStringNamed(NAME, element); diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformService.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformService.java index abd3b7400..b3db83c39 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformService.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformService.java @@ -47,4 +47,8 @@ public interface ChargeDropdownReadPlatformService { List retrieveSharesCollectionTimeTypes(); + List retrieveCurrentAccountCalculationTypes(); + + List retrieveCurrentAccountCollectionTimeTypes(); + } diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformServiceImpl.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformServiceImpl.java index f7706093c..c97ad517a 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformServiceImpl.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeDropdownReadPlatformServiceImpl.java @@ -124,4 +124,18 @@ public class ChargeDropdownReadPlatformServiceImpl implements ChargeDropdownRead return Arrays.asList(chargeTimeType(ChargeTimeType.SHAREACCOUNT_ACTIVATION), chargeTimeType(ChargeTimeType.SHARE_PURCHASE), chargeTimeType(ChargeTimeType.SHARE_REDEEM)); } + + @Override + public List retrieveCurrentAccountCalculationTypes() { + return Arrays.asList(chargeCalculationType(ChargeCalculationType.FLAT), + chargeCalculationType(ChargeCalculationType.PERCENT_OF_AMOUNT)); + } + + @Override + public List retrieveCurrentAccountCollectionTimeTypes() { + return Arrays.asList(chargeTimeType(ChargeTimeType.SPECIFIED_DUE_DATE), chargeTimeType(ChargeTimeType.SAVINGS_ACTIVATION), + chargeTimeType(ChargeTimeType.WITHDRAWAL_FEE), chargeTimeType(ChargeTimeType.ANNUAL_FEE), + chargeTimeType(ChargeTimeType.MONTHLY_FEE), chargeTimeType(ChargeTimeType.WEEKLY_FEE), + chargeTimeType(ChargeTimeType.OVERDRAFT_FEE), chargeTimeType(ChargeTimeType.SAVINGS_NOACTIVITY_FEE)); + } } diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeEnumerations.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeEnumerations.java index 44a8d7f5a..4ebae4b39 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeEnumerations.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeEnumerations.java @@ -130,6 +130,10 @@ public final class ChargeEnumerations { case SHARES: optionData = new EnumOptionData(ChargeAppliesTo.SHARES.getValue().longValue(), ChargeAppliesTo.SHARES.getCode(), "Shares"); break; + case CURRENT_ACCOUNT: + optionData = new EnumOptionData(ChargeAppliesTo.CURRENT_ACCOUNT.getValue().longValue(), + ChargeAppliesTo.CURRENT_ACCOUNT.getCode(), "Current Account"); + break; default: optionData = new EnumOptionData(ChargeAppliesTo.INVALID.getValue().longValue(), ChargeAppliesTo.INVALID.getCode(), "Invalid"); diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index 16f9ad9c2..46e6a4cd4 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -60,6 +60,7 @@ public enum JobName { ACCRUAL_ACTIVITY_POSTING("Accrual Activity Posting"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_SAVINGS_WITH_INCOME_POSTED_AS_TRANSACTIONS("Add Accrual Transactions For Savings"), // JOURNAL_ENTRY_AGGREGATION("Journal Entry Aggregation"), // + FETCH_RBI_RSS_FEEDS("Fetch RBI RSS Feeds"), // ; // private final String name; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectProjectData.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectProjectData.java new file mode 100644 index 000000000..28f5c589d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectProjectData.java @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.openproject.data; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +public final class OpenProjectProjectData { + + private Long id; + private String identifier; + private String name; + private boolean active; + private String description; + private String createdAt; + private String updatedAt; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectStatusSummaryData.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectStatusSummaryData.java new file mode 100644 index 000000000..4d76bfa6d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectStatusSummaryData.java @@ -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.infrastructure.openproject.data; + +import java.util.Map; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +public final class OpenProjectStatusSummaryData { + + private int totalWorkPackages; + private Map byStatus; + private Map byType; + private Map byPriority; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectWorkPackageData.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectWorkPackageData.java new file mode 100644 index 000000000..ffc193438 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/openproject/data/OpenProjectWorkPackageData.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.openproject.data; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +public final class OpenProjectWorkPackageData { + + private Long id; + private String subject; + private String description; + private String typeName; + private String statusName; + private String priorityName; + private String assigneeName; + private Integer percentageDone; + private String startDate; + private String dueDate; + private String createdAt; + private String updatedAt; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/data/RbiFeedItemData.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/data/RbiFeedItemData.java new file mode 100644 index 000000000..6830b116f --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/data/RbiFeedItemData.java @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.rbifeed.data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +public final class RbiFeedItemData implements Serializable { + + private Long id; + private String title; + private String description; + private String link; + private LocalDateTime pubDate; + private String category; + private LocalDateTime fetchedOn; + private Long openProjectWpId; + private String openProjectWpStatus; + private Integer openProjectWpProgress; + + public static RbiFeedItemData instance(final Long id, final String title, final String description, final String link, + final LocalDateTime pubDate, final String category, final LocalDateTime fetchedOn, + final Long openProjectWpId, final String openProjectWpStatus, final Integer openProjectWpProgress) { + return new RbiFeedItemData().setId(id).setTitle(title).setDescription(description).setLink(link).setPubDate(pubDate) + .setCategory(category).setFetchedOn(fetchedOn) + .setOpenProjectWpId(openProjectWpId).setOpenProjectWpStatus(openProjectWpStatus) + .setOpenProjectWpProgress(openProjectWpProgress); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItem.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItem.java new file mode 100644 index 000000000..000d399ab --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItem.java @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.rbifeed.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +@Entity +@Table(name = "m_rbi_feed_item", uniqueConstraints = { + @UniqueConstraint(columnNames = { "link" }, name = "uk_rbi_feed_item_link") }) +@Getter +@Setter +@NoArgsConstructor +@Accessors(chain = true) +public class RbiFeedItem extends AbstractPersistableCustom { + + @Column(name = "title", nullable = false, length = 1000) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "link", nullable = false, length = 1000) + private String link; + + @Column(name = "pub_date") + private LocalDateTime pubDate; + + @Column(name = "category", nullable = false, length = 50) + private String category; + + @Column(name = "fetched_on") + private LocalDateTime fetchedOn; + + @Column(name = "openproject_wp_id") + private Long openProjectWpId; + + @Column(name = "openproject_wp_status", length = 100) + private String openProjectWpStatus; + + @Column(name = "openproject_wp_progress") + private Integer openProjectWpProgress; +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItemRepository.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItemRepository.java new file mode 100644 index 000000000..e5479f36b --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/rbifeed/domain/RbiFeedItemRepository.java @@ -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.infrastructure.rbifeed.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface RbiFeedItemRepository extends JpaRepository, JpaSpecificationExecutor { + + boolean existsByLink(String link); + + List findByCategoryOrderByPubDateDesc(String category); + + List findAllByOrderByPubDateDesc(); + + List findByCategoryAndOpenProjectWpIdIsNotNull(String category); + + List findByCategoryAndOpenProjectWpIdIsNull(String category); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/data/ChargeData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/data/ChargeData.java index 789c15491..13e731430 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/data/ChargeData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/data/ChargeData.java @@ -87,6 +87,8 @@ public final class ChargeData implements Comparable, Serializable { private final List clientChargeTimeTypeOptions; private final List shareChargeCalculationTypeOptions; private final List shareChargeTimeTypeOptions; + private final List currentAccountChargeCalculationTypeOptions; + private final List currentAccountChargeTimeTypeOptions; private final List feeFrequencyOptions; @@ -113,6 +115,8 @@ public final class ChargeData implements Comparable, Serializable { .taxGroupOptions(template.getTaxGroupOptions()) .shareChargeCalculationTypeOptions(template.getShareChargeCalculationTypeOptions()) .shareChargeTimeTypeOptions(template.getShareChargeTimeTypeOptions()) + .currentAccountChargeCalculationTypeOptions(template.getCurrentAccountChargeCalculationTypeOptions()) + .currentAccountChargeTimeTypeOptions(template.getCurrentAccountChargeTimeTypeOptions()) .accountMappingForChargeConfig(template.getAccountMappingForChargeConfig()) .expenseAccountOptions(template.getExpenseAccountOptions()).assetAccountOptions(template.getAssetAccountOptions()).build(); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeTimeType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeTimeType.java index f713062c5..c1a0bbf12 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeTimeType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeTimeType.java @@ -72,6 +72,13 @@ public enum ChargeTimeType { ChargeTimeType.SAVINGS_NOACTIVITY_FEE.getValue() }; } + public static Object[] validCurrentAccountValues() { + return new Integer[] { ChargeTimeType.SPECIFIED_DUE_DATE.getValue(), ChargeTimeType.SAVINGS_ACTIVATION.getValue(), + ChargeTimeType.SAVINGS_CLOSURE.getValue(), ChargeTimeType.WITHDRAWAL_FEE.getValue(), ChargeTimeType.ANNUAL_FEE.getValue(), + ChargeTimeType.MONTHLY_FEE.getValue(), ChargeTimeType.OVERDRAFT_FEE.getValue(), ChargeTimeType.WEEKLY_FEE.getValue(), + ChargeTimeType.SAVINGS_NOACTIVITY_FEE.getValue() }; + } + public static Object[] validClientValues() { return new Integer[] { ChargeTimeType.SPECIFIED_DUE_DATE.getValue() }; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java index e95a41521..8835b7a4d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java @@ -181,6 +181,7 @@ public class ClientApiConstants { public static final String datatables = "datatables"; public static final String obligeeData = "ObligeeDetails"; + public static final String kycParamName = "kyc"; public static final String clientEntityName = "clients"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMemberRequest.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMemberRequest.java index a7b41d6c2..66dfc0d4c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMemberRequest.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMemberRequest.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.client.data; import java.io.Serial; import java.io.Serializable; +import java.math.BigDecimal; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -53,4 +54,7 @@ public class ClientFamilyMemberRequest implements Serializable { private Long id; private Long age; private Long professionId; + private String nominationType; + private BigDecimal percentageShare; + private String remarks; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java index 544100686..252b3b4de 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientFamilyMembersData.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.client.data; import java.io.Serializable; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.Collection; import lombok.Builder; @@ -66,6 +67,12 @@ public final class ClientFamilyMembersData implements Serializable { private final Boolean isDependent; + private final String nominationType; + + private final BigDecimal percentageShare; + + private final String remarks; + // template holder private final Collection relationshipIdOptions; private final Collection genderIdOptions; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java new file mode 100644 index 000000000..7f21bce5e --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -0,0 +1,380 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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 jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import java.util.LinkedHashMap; +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; + +/** + * Represents a client's KYC (Know Your Customer) record as mandated by the Reserve Bank of India + * Master Direction on KYC (updated 2023). + * + *

One record per client. Contains: + *

    + *
  • PAN verification (Permanent Account Number – Income Tax Dept)
  • + *
  • Aadhaar verification (12-digit UIDAI number, stored masked)
  • + *
  • GSTIN verification (for non-individual/business clients)
  • + *
  • Risk category, CKYCRR number, expiry & lifecycle timestamps
  • + *
+ */ +@Entity +@Table(name = "m_client_kyc", uniqueConstraints = { + @UniqueConstraint(columnNames = { "client_id" }, name = "uq_m_client_kyc_client_id") }) +public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + /** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */ + @Column(name = "kyc_status", nullable = false) + private Integer kycStatus; + + /** 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL */ + @Column(name = "kyc_type", nullable = false) + private Integer kycType; + + /** 1 = LOW, 2 = MEDIUM, 3 = HIGH */ + @Column(name = "risk_category", nullable = false) + private Integer riskCategory; + + // ── PAN (Permanent Account Number) ────────────────────────────────────── + @Column(name = "pan_number", length = 10) + private String panNumber; + + @Column(name = "pan_verified", nullable = false) + private boolean panVerified; + + @Column(name = "pan_verified_on") + private LocalDate panVerifiedOn; + + @Column(name = "pan_full_name", length = 200) + private String panFullName; + + /** Y = YES, N = NO, NA = Not Available (Aadhaar seeding status on PAN card) */ + @Column(name = "pan_aadhaar_seeding_status", length = 5) + private String panAadhaarSeedingStatus; + + /** PAN card category: individual, company, firm, etc. */ + @Column(name = "pan_category", length = 50) + private String panCategory; + + /** PAN validation status: valid or invalid */ + @Column(name = "pan_status", length = 20) + private String panStatus; + + // ── Aadhaar ────────────────────────────────────────────────────────────── + /** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */ + @Column(name = "aadhaar_number_masked", length = 12) + private String aadhaarNumberMasked; + + @Column(name = "aadhaar_verified", nullable = false) + private boolean aadhaarVerified; + + @Column(name = "aadhaar_verified_on") + private LocalDate aadhaarVerifiedOn; + + // ── GSTIN (mandatory for NON_INDIVIDUAL clients) ───────────────────────── + @Column(name = "gstin_number", length = 15) + private String gstinNumber; + + @Column(name = "gstin_verified", nullable = false) + private boolean gstinVerified; + + @Column(name = "gstin_verified_on") + private LocalDate gstinVerifiedOn; + + // ── CKYCRR ─────────────────────────────────────────────────────────────── + /** Central KYC Records Registry number issued by CERSAI */ + @Column(name = "ckyc_number", length = 14) + private String ckycNumber; + + // ── Lifecycle dates ─────────────────────────────────────────────────────── + @Column(name = "initiated_on") + private LocalDate initiatedOn; + + @Column(name = "verified_on") + private LocalDate verifiedOn; + + @Column(name = "rejected_on") + private LocalDate rejectedOn; + + /** KYC expiry date calculated based on risk category and verification date */ + @Column(name = "expiry_date") + private LocalDate expiryDate; + + @Column(name = "remarks", length = 500) + private String remarks; + + // ───────────────────────────────────────────────────────────────────────── + + protected ClientKyc() {} + + private ClientKyc(final Client client, final Integer kycType, final Integer riskCategory, + final String panNumber, final String aadhaarNumberMasked, final String gstinNumber, + final String remarks) { + this.client = client; + this.kycStatus = ClientKycStatus.PENDING.getValue(); + this.kycType = kycType; + this.riskCategory = riskCategory; + this.panNumber = StringUtils.defaultIfEmpty(panNumber, null); + this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(aadhaarNumberMasked, null); + this.gstinNumber = StringUtils.defaultIfEmpty(gstinNumber, null); + this.remarks = StringUtils.defaultIfEmpty(remarks, null); + this.panVerified = false; + this.aadhaarVerified = false; + this.gstinVerified = false; + this.initiatedOn = LocalDate.now(); + } + + public static ClientKyc initiateKyc(final Client client, final JsonCommand command) { + final Integer kycType = command.integerValueOfParameterNamed("kycType"); + final Integer riskCategory = command.integerValueOfParameterNamed("riskCategory"); + final String panNumber = command.stringValueOfParameterNamed("panNumber"); + final String aadhaarNumberMasked = command.stringValueOfParameterNamed("aadhaarNumberMasked"); + final String gstinNumber = command.stringValueOfParameterNamed("gstinNumber"); + final String remarks = command.stringValueOfParameterNamed("remarks"); + return new ClientKyc(client, kycType, riskCategory, panNumber, aadhaarNumberMasked, gstinNumber, remarks); + } + + /** + * Factory method used when KYC is initiated inline during client registration. + * Accepts individual field values directly (avoids needing a JsonCommand sub-object). + */ + public static ClientKyc initiateKyc(final Client client, final Integer kycType, final Integer riskCategory, + final String panNumber, final String aadhaarNumberMasked, final String gstinNumber, final String remarks) { + return new ClientKyc(client, kycType, riskCategory, panNumber, aadhaarNumberMasked, gstinNumber, remarks); + } + + public Map update(final JsonCommand command) { + final Map actualChanges = new LinkedHashMap<>(); + + final String panNumberParam = "panNumber"; + if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) { + final String newValue = command.stringValueOfParameterNamed(panNumberParam); + actualChanges.put(panNumberParam, newValue); + this.panNumber = StringUtils.defaultIfEmpty(newValue, null); + } + + final String aadhaarParam = "aadhaarNumberMasked"; + if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) { + final String newValue = command.stringValueOfParameterNamed(aadhaarParam); + actualChanges.put(aadhaarParam, newValue); + this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null); + } + + final String gstinParam = "gstinNumber"; + if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) { + final String newValue = command.stringValueOfParameterNamed(gstinParam); + actualChanges.put(gstinParam, newValue); + this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null); + } + + final String riskParam = "riskCategory"; + if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) { + final Integer newValue = command.integerValueOfParameterNamed(riskParam); + actualChanges.put(riskParam, newValue); + this.riskCategory = newValue; + } + + final String remarksParam = "remarks"; + if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) { + final String newValue = command.stringValueOfParameterNamed(remarksParam); + actualChanges.put(remarksParam, newValue); + this.remarks = StringUtils.defaultIfEmpty(newValue, null); + } + + return actualChanges; + } + + /** Called when documents are submitted and 3rd-party verification is initiated. */ + public void markInProgress() { + this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue(); + } + + /** 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.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null); + + final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory); + if (risk.getReKycYears() > 0) { + this.expiryDate = this.verifiedOn.plusYears(risk.getReKycYears()); + } + } + + /** Called when verification fails. */ + public void markRejected(final String remarks) { + this.kycStatus = ClientKycStatus.REJECTED.getValue(); + this.rejectedOn = LocalDate.now(); + this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks); + } + + public void markPanVerified(final String fullName, final String aadhaarSeedingStatus, + final String category, final String status) { + this.panVerified = true; + this.panVerifiedOn = LocalDate.now(); + this.panFullName = StringUtils.defaultIfEmpty(fullName, null); + this.panAadhaarSeedingStatus = normalizeAadhaarSeedingStatus(aadhaarSeedingStatus); + this.panCategory = StringUtils.defaultIfEmpty(category, null); + this.panStatus = StringUtils.defaultIfEmpty(status, null); + } + + /** Normalize aadhaar seeding status: y→Y, n→N, anything else→NA */ + private String normalizeAadhaarSeedingStatus(final String raw) { + if (raw == null || raw.isBlank()) { + return "NA"; + } + final String trimmed = raw.trim().toUpperCase(); + if ("Y".equals(trimmed) || "YES".equals(trimmed)) { + return "Y"; + } + if ("N".equals(trimmed) || "NO".equals(trimmed)) { + return "N"; + } + return "NA"; + } + + public void markAadhaarVerified() { + this.aadhaarVerified = true; + this.aadhaarVerifiedOn = LocalDate.now(); + } + + public void markGstinVerified() { + this.gstinVerified = true; + this.gstinVerifiedOn = LocalDate.now(); + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + public Client getClient() { + return client; + } + + public ClientKycStatus getKycStatus() { + return ClientKycStatus.fromInt(this.kycStatus); + } + + public Integer getKycStatusValue() { + return kycStatus; + } + + public ClientKycType getKycType() { + return ClientKycType.fromInt(this.kycType); + } + + public Integer getKycTypeValue() { + return kycType; + } + + public KycRiskCategory getRiskCategory() { + return KycRiskCategory.fromInt(this.riskCategory); + } + + public Integer getRiskCategoryValue() { + return riskCategory; + } + + public String getPanNumber() { + return panNumber; + } + + public boolean isPanVerified() { + return panVerified; + } + + public LocalDate getPanVerifiedOn() { + return panVerifiedOn; + } + + public String getPanFullName() { + return panFullName; + } + + public String getPanAadhaarSeedingStatus() { + return panAadhaarSeedingStatus; + } + + public String getPanCategory() { + return panCategory; + } + + public String getPanStatus() { + return panStatus; + } + + public String getAadhaarNumberMasked() { + return aadhaarNumberMasked; + } + + public boolean isAadhaarVerified() { + return aadhaarVerified; + } + + public LocalDate getAadhaarVerifiedOn() { + return aadhaarVerifiedOn; + } + + public String getGstinNumber() { + return gstinNumber; + } + + public boolean isGstinVerified() { + return gstinVerified; + } + + public LocalDate getGstinVerifiedOn() { + return gstinVerifiedOn; + } + + public String getCkycNumber() { + return ckycNumber; + } + + public LocalDate getInitiatedOn() { + return initiatedOn; + } + + public LocalDate getVerifiedOn() { + return verifiedOn; + } + + public LocalDate getRejectedOn() { + return rejectedOn; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public String getRemarks() { + return remarks; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java new file mode 100644 index 000000000..7d7ea590d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepository.java @@ -0,0 +1,38 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ClientKycRepository extends JpaRepository { + + Optional findByClientId(Long clientId); + + @Query("SELECT k FROM ClientKyc k WHERE k.client.id = :clientId") + Optional findKycByClientId(@Param("clientId") Long clientId); + + boolean existsByClientId(Long clientId); + + @Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status") + List findAllByStatus(@Param("status") Integer status); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepositoryWrapper.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepositoryWrapper.java new file mode 100644 index 000000000..24e5697fb --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycRepositoryWrapper.java @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Wraps {@link ClientKycRepository} to surface consistent not-found exceptions. + */ +@Service +@RequiredArgsConstructor +public class ClientKycRepositoryWrapper { + + private final ClientKycRepository repository; + + @Transactional(readOnly = true) + public ClientKyc findOneWithNotFoundDetection(final Long id) { + return repository.findById(id).orElseThrow( + () -> new org.apache.fineract.portfolio.client.exception.ClientKycNotFoundException(id)); + } + + @Transactional(readOnly = true) + public ClientKyc findByClientIdWithNotFoundDetection(final Long clientId) { + return repository.findByClientId(clientId).orElseThrow( + () -> new org.apache.fineract.portfolio.client.exception.ClientKycNotFoundException(clientId)); + } + + public ClientKyc saveAndFlush(final ClientKyc kyc) { + return repository.saveAndFlush(kyc); + } + + public void delete(final ClientKyc kyc) { + repository.delete(kyc); + } + + public boolean existsByClientId(final Long clientId) { + return repository.existsByClientId(clientId); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycStatus.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycStatus.java new file mode 100644 index 000000000..7007fdb8a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycStatus.java @@ -0,0 +1,88 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +/** + * KYC status lifecycle as per RBI Master Direction on KYC (updated 2023). + * + *
+ *   PENDING     -> IN_PROGRESS  (documents submitted)
+ *   IN_PROGRESS -> VERIFIED     (3rd-party check passed)
+ *   IN_PROGRESS -> REJECTED     (3rd-party check failed / fraud detected)
+ *   VERIFIED    -> EXPIRED      (re-KYC due date crossed)
+ *   EXPIRED     -> IN_PROGRESS  (re-KYC initiated)
+ * 
+ */ +public enum ClientKycStatus { + + INVALID(0, "clientKycStatus.invalid"), + PENDING(100, "clientKycStatus.pending"), + IN_PROGRESS(200, "clientKycStatus.inProgress"), + VERIFIED(300, "clientKycStatus.verified"), + REJECTED(400, "clientKycStatus.rejected"), + EXPIRED(500, "clientKycStatus.expired"); + + private final Integer value; + private final String code; + + ClientKycStatus(final Integer value, final String code) { + this.value = value; + this.code = code; + } + + public Integer getValue() { + return value; + } + + public String getCode() { + return code; + } + + public boolean isPending() { + return this == PENDING; + } + + public boolean isInProgress() { + return this == IN_PROGRESS; + } + + public boolean isVerified() { + return this == VERIFIED; + } + + public boolean isRejected() { + return this == REJECTED; + } + + public boolean isExpired() { + return this == EXPIRED; + } + + public static ClientKycStatus fromInt(final Integer value) { + if (value == null) { + return INVALID; + } + for (ClientKycStatus status : values()) { + if (status.getValue().equals(value)) { + return status; + } + } + return INVALID; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycType.java new file mode 100644 index 000000000..5f3ae797a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKycType.java @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +/** + * KYC entity type as defined under RBI Master Direction on KYC. + * INDIVIDUAL – natural persons. + * NON_INDIVIDUAL – companies, trusts, partnerships, HUFs, etc. + */ +public enum ClientKycType { + + INVALID(0, "clientKycType.invalid"), + INDIVIDUAL(1, "clientKycType.individual"), + NON_INDIVIDUAL(2, "clientKycType.nonIndividual"); + + private final Integer value; + private final String code; + + ClientKycType(final Integer value, final String code) { + this.value = value; + this.code = code; + } + + public Integer getValue() { + return value; + } + + public String getCode() { + return code; + } + + public boolean isIndividual() { + return this == INDIVIDUAL; + } + + public boolean isNonIndividual() { + return this == NON_INDIVIDUAL; + } + + public static ClientKycType fromInt(final Integer value) { + if (value == null) { + return INVALID; + } + for (ClientKycType type : values()) { + if (type.getValue().equals(value)) { + return type; + } + } + return INVALID; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycRiskCategory.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycRiskCategory.java new file mode 100644 index 000000000..5d6b9a29c --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/KycRiskCategory.java @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.domain; + +/** + * Customer risk category used for periodic re-KYC frequency as per RBI guidelines. + * + *
    + *
  • LOW – re-KYC every 10 years
  • + *
  • MEDIUM – re-KYC every 8 years
  • + *
  • HIGH – re-KYC every 2 years
  • + *
+ */ +public enum KycRiskCategory { + + INVALID(0, "kycRiskCategory.invalid", 0), + LOW(1, "kycRiskCategory.low", 10), + MEDIUM(2, "kycRiskCategory.medium", 8), + HIGH(3, "kycRiskCategory.high", 2); + + private final Integer value; + private final String code; + /** Re-KYC validity in years */ + private final int reKycYears; + + KycRiskCategory(final Integer value, final String code, final int reKycYears) { + this.value = value; + this.code = code; + this.reKycYears = reKycYears; + } + + public Integer getValue() { + return value; + } + + public String getCode() { + return code; + } + + public int getReKycYears() { + return reKycYears; + } + + public static KycRiskCategory fromInt(final Integer value) { + if (value == null) { + return INVALID; + } + for (KycRiskCategory cat : values()) { + if (cat.getValue().equals(value)) { + return cat; + } + } + return INVALID; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycAlreadyExistsException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycAlreadyExistsException.java new file mode 100644 index 000000000..e125852e7 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycAlreadyExistsException.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ClientKycAlreadyExistsException extends AbstractPlatformDomainRuleException { + + public ClientKycAlreadyExistsException(final Long clientId) { + super("error.msg.client.kyc.already.exists", + "A KYC record already exists for client with identifier " + clientId + + ". Use the update endpoint to modify it.", + clientId); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycInvalidStateTransitionException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycInvalidStateTransitionException.java new file mode 100644 index 000000000..756897375 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycInvalidStateTransitionException.java @@ -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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ClientKycInvalidStateTransitionException extends AbstractPlatformDomainRuleException { + + public ClientKycInvalidStateTransitionException(final String action, final String currentState) { + super("error.msg.client.kyc.invalid.state.transition", + "KYC action [" + action + "] is not permitted when the KYC status is [" + currentState + "]", + action, currentState); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycNotFoundException.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycNotFoundException.java new file mode 100644 index 000000000..b3b1df1e1 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/exception/ClientKycNotFoundException.java @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class ClientKycNotFoundException extends AbstractPlatformResourceNotFoundException { + + public ClientKycNotFoundException(final Long id) { + super("error.msg.client.kyc.not.found", "Client KYC record with identifier " + id + " does not exist", id); + } + + public ClientKycNotFoundException(final Long clientId, final boolean byClient) { + super("error.msg.client.kyc.not.found", + "No KYC record found for client with identifier " + clientId, clientId); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java new file mode 100644 index 000000000..1b5d83dcd --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java @@ -0,0 +1,82 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.mobilelookup.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.Getter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +/** + * Activity log for each DIP / MNRL mobile number lookup request. + */ +@Entity +@Table(name = "m_mobile_lookup_log") +@Getter +public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "mobile_number", nullable = false, length = 15) + private String mobileNumber; + + /** CLEAN, SUSPECTED, DISCONNECTED, SUSPECTED_AND_DISCONNECTED, ERROR */ + @Column(name = "lookup_status", nullable = false, length = 50) + private String lookupStatus; + + @Column(name = "suspected", nullable = false) + private boolean suspected; + + @Column(name = "disconnected", nullable = false) + private boolean disconnected; + + @Column(name = "response_message", length = 500) + private String responseMessage; + + /** Full JSON response from the external API (for audit trail) */ + @Column(name = "raw_response", columnDefinition = "TEXT") + private String rawResponse; + + /** CLIENT_REGISTRATION, MANUAL_CHECK, API */ + @Column(name = "lookup_source", length = 50) + private String lookupSource; + + @Column(name = "client_id") + private Long clientId; + + @Column(name = "looked_up_on", nullable = false) + private OffsetDateTime lookedUpOn; + + protected MobileLookupLog() {} + + public MobileLookupLog(final String mobileNumber, final String lookupStatus, + final boolean suspected, final boolean disconnected, + final String responseMessage, final String rawResponse, + final String lookupSource, final Long clientId) { + this.mobileNumber = mobileNumber; + this.lookupStatus = lookupStatus; + this.suspected = suspected; + this.disconnected = disconnected; + this.responseMessage = responseMessage; + this.rawResponse = rawResponse; + this.lookupSource = lookupSource; + this.clientId = clientId; + this.lookedUpOn = OffsetDateTime.now(); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLogRepository.java new file mode 100644 index 000000000..7bacfd3af --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLogRepository.java @@ -0,0 +1,38 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.mobilelookup.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface MobileLookupLogRepository extends JpaRepository { + + @Query("SELECT l FROM MobileLookupLog l ORDER BY l.lookedUpOn DESC") + List findAllOrderByLookedUpOnDesc(); + + @Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC") + List findByMobileNumber(@Param("mobileNumber") String mobileNumber); + + @Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC") + List findByLookupStatus(@Param("status") String status); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsAlias.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsAlias.java new file mode 100644 index 000000000..4bf9adf94 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsAlias.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sanctions.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +/** + * Represents an alias (also-known-as) name for a sanctions list entry. + */ +@Entity +@Table(name = "m_sanctions_alias") +public class SanctionsAlias extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sanctions_entry_id", nullable = false) + private SanctionsEntry sanctionsEntry; + + @Column(name = "alias_quality", length = 50) + private String aliasQuality; + + @Column(name = "alias_name", nullable = false, length = 500) + private String aliasName; + + protected SanctionsAlias() {} + + public SanctionsAlias(final String aliasQuality, final String aliasName) { + this.aliasQuality = aliasQuality; + this.aliasName = aliasName; + } + + public void setSanctionsEntry(SanctionsEntry sanctionsEntry) { + this.sanctionsEntry = sanctionsEntry; + } + + public SanctionsEntry getSanctionsEntry() { return sanctionsEntry; } + public String getAliasQuality() { return aliasQuality; } + public String getAliasName() { return aliasName; } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntry.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntry.java new file mode 100644 index 000000000..c7dad77fc --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntry.java @@ -0,0 +1,157 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.sanctions.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +/** + * Represents an individual or entity from the UN Security Council consolidated sanctions list. + */ +@Entity +@Table(name = "m_sanctions_entry") +public class SanctionsEntry extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "data_id", nullable = false, length = 50) + private String dataId; + + /** INDIVIDUAL or ENTITY */ + @Column(name = "entry_type", nullable = false, length = 20) + private String entryType; + + @Column(name = "first_name", length = 255) + private String firstName; + + @Column(name = "second_name", length = 255) + private String secondName; + + @Column(name = "third_name", length = 255) + private String thirdName; + + @Column(name = "fourth_name", length = 255) + private String fourthName; + + @Column(name = "full_name", length = 500) + private String fullName; + + @Column(name = "un_list_type", length = 255) + private String unListType; + + @Column(name = "reference_number", length = 255) + private String referenceNumber; + + @Column(name = "listed_on") + private LocalDate listedOn; + + @Column(name = "gender", length = 20) + private String gender; + + @Column(name = "nationality", length = 500) + private String nationality; + + @Column(name = "comments", columnDefinition = "TEXT") + private String comments; + + @Column(name = "designation", columnDefinition = "TEXT") + private String designation; + + @Column(name = "date_of_birth", length = 500) + private String dateOfBirth; + + @Column(name = "place_of_birth", length = 500) + private String placeOfBirth; + + @Column(name = "is_active", nullable = false) + private boolean active = true; + + @OneToMany(mappedBy = "sanctionsEntry", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List aliases = new ArrayList<>(); + + protected SanctionsEntry() {} + + public SanctionsEntry(final String dataId, final String entryType, final String firstName, + final String secondName, final String thirdName, final String fourthName, + final String unListType, final String referenceNumber, final LocalDate listedOn, + final String gender, final String nationality, final String comments, + final String designation, final String dateOfBirth, final String placeOfBirth) { + this.dataId = dataId; + this.entryType = entryType; + this.firstName = firstName; + this.secondName = secondName; + this.thirdName = thirdName; + this.fourthName = fourthName; + this.fullName = buildFullName(firstName, secondName, thirdName, fourthName); + this.unListType = unListType; + this.referenceNumber = referenceNumber; + this.listedOn = listedOn; + this.gender = gender; + this.nationality = nationality; + this.comments = comments; + this.designation = designation; + this.dateOfBirth = dateOfBirth; + this.placeOfBirth = placeOfBirth; + } + + private static String buildFullName(String... parts) { + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + if (part != null && !part.isBlank()) { + if (sb.length() > 0) { + sb.append(" "); + } + sb.append(part.trim()); + } + } + return sb.length() > 0 ? sb.toString() : null; + } + + public void addAlias(SanctionsAlias alias) { + aliases.add(alias); + alias.setSanctionsEntry(this); + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + public String getDataId() { return dataId; } + public String getEntryType() { return entryType; } + public String getFirstName() { return firstName; } + public String getSecondName() { return secondName; } + public String getThirdName() { return thirdName; } + public String getFourthName() { return fourthName; } + public String getFullName() { return fullName; } + public String getUnListType() { return unListType; } + public String getReferenceNumber() { return referenceNumber; } + public LocalDate getListedOn() { return listedOn; } + public String getGender() { return gender; } + public String getNationality() { return nationality; } + public String getComments() { return comments; } + public String getDesignation() { return designation; } + public String getDateOfBirth() { return dateOfBirth; } + public String getPlaceOfBirth() { return placeOfBirth; } + public boolean isActive() { return active; } + public List getAliases() { return aliases; } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntryRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntryRepository.java new file mode 100644 index 000000000..0dbe5c012 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsEntryRepository.java @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sanctions.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SanctionsEntryRepository extends JpaRepository { + + @Query("SELECT s FROM SanctionsEntry s WHERE s.active = true AND LOWER(s.fullName) LIKE LOWER(CONCAT('%', :name, '%'))") + List searchByFullName(@Param("name") String name); + + @Query("SELECT DISTINCT s FROM SanctionsEntry s LEFT JOIN s.aliases a WHERE s.active = true " + + "AND (LOWER(s.fullName) LIKE LOWER(CONCAT('%', :name, '%')) " + + "OR LOWER(a.aliasName) LIKE LOWER(CONCAT('%', :name, '%')))") + List searchByNameOrAlias(@Param("name") String name); + + boolean existsByDataId(String dataId); + + void deleteAllByActiveTrue(); + + long countByActiveTrue(); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java new file mode 100644 index 000000000..c887f6ee6 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java @@ -0,0 +1,90 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.sanctions.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +/** + * Activity log for each sanctions screening request. + */ +@Entity +@Table(name = "m_sanctions_screening_log") +public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "screened_name", nullable = false, length = 500) + private String screenedName; + + /** FLAGGED or CLEAR */ + @Column(name = "screening_result", nullable = false, length = 20) + private String screeningResult; + + @Column(name = "match_count", nullable = false) + private Integer matchCount; + + @Column(name = "matched_entry_ids", length = 1000) + private String matchedEntryIds; + + @Column(name = "matched_names", length = 2000) + private String matchedNames; + + /** CLIENT_CREATION, MANUAL_CHECK, API */ + @Column(name = "screening_source", length = 50) + private String screeningSource; + + @Column(name = "client_id") + private Long clientId; + + @Column(name = "ip_address", length = 50) + private String ipAddress; + + @Column(name = "screened_on", nullable = false) + private OffsetDateTime screenedOn; + + protected SanctionsScreeningLog() {} + + public SanctionsScreeningLog(final String screenedName, final String screeningResult, + final Integer matchCount, final String matchedEntryIds, final String matchedNames, + final String screeningSource, final Long clientId, final String ipAddress) { + this.screenedName = screenedName; + this.screeningResult = screeningResult; + this.matchCount = matchCount; + this.matchedEntryIds = matchedEntryIds; + this.matchedNames = matchedNames; + this.screeningSource = screeningSource; + this.clientId = clientId; + this.ipAddress = ipAddress; + this.screenedOn = OffsetDateTime.now(); + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + public String getScreenedName() { return screenedName; } + public String getScreeningResult() { return screeningResult; } + public Integer getMatchCount() { return matchCount; } + public String getMatchedEntryIds() { return matchedEntryIds; } + public String getMatchedNames() { return matchedNames; } + public String getScreeningSource() { return screeningSource; } + public Long getClientId() { return clientId; } + public String getIpAddress() { return ipAddress; } + public OffsetDateTime getScreenedOn() { return screenedOn; } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java new file mode 100644 index 000000000..2eaa457ed --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sanctions.domain; + +import java.time.OffsetDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SanctionsScreeningLogRepository extends JpaRepository { + + @Query("SELECT l FROM SanctionsScreeningLog l ORDER BY l.screenedOn DESC") + List findAllOrderByScreenedOnDesc(); + + @Query("SELECT l FROM SanctionsScreeningLog l WHERE l.screeningResult = :result ORDER BY l.screenedOn DESC") + List findByScreeningResult(@Param("result") String result); + + @Query("SELECT l FROM SanctionsScreeningLog l WHERE l.screenedOn >= :from AND l.screenedOn <= :to ORDER BY l.screenedOn DESC") + List findByDateRange(@Param("from") OffsetDateTime from, @Param("to") OffsetDateTime to); + + @Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC") + List findByScreenedNameContaining(@Param("name") String name); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/api/OpenProjectApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/api/OpenProjectApiResource.java new file mode 100644 index 000000000..8567e2350 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/api/OpenProjectApiResource.java @@ -0,0 +1,187 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.openproject.api; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.UriInfo; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.openproject.data.OpenProjectProjectData; +import org.apache.fineract.infrastructure.openproject.data.OpenProjectStatusSummaryData; +import org.apache.fineract.infrastructure.openproject.data.OpenProjectWorkPackageData; +import org.apache.fineract.infrastructure.openproject.service.OpenProjectService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.stereotype.Component; + +@Path("/v1/openproject") +@Component +@Tag(name = "OpenProject", description = "Integration with OpenProject for project management. " + + "Provides access to project details, work packages, and status summaries.") +@RequiredArgsConstructor +@Slf4j +public class OpenProjectApiResource { + + private static final Set PROJECT_RESPONSE_PARAMS = new HashSet<>( + Arrays.asList("id", "identifier", "name", "active", "description", "createdAt", "updatedAt")); + private static final Set WORK_PACKAGE_RESPONSE_PARAMS = new HashSet<>( + Arrays.asList("id", "subject", "description", "typeName", "statusName", "priorityName", + "assigneeName", "percentageDone", "startDate", "dueDate", "createdAt", "updatedAt")); + private static final Set SUMMARY_RESPONSE_PARAMS = new HashSet<>( + Arrays.asList("totalWorkPackages", "byStatus", "byType", "byPriority")); + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "OPENPROJECT"; + + private final PlatformSecurityContext context; + private final OpenProjectService openProjectService; + private final DefaultToApiJsonSerializer projectSerializer; + private final DefaultToApiJsonSerializer workPackageSerializer; + private final DefaultToApiJsonSerializer summarySerializer; + private final ApiRequestParameterHelper apiRequestParameterHelper; + + @GET + @Path("project") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve OpenProject project details", + description = "Returns the configured OpenProject project information.") + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = OpenProjectProjectData.class))) + public String getProject(@Context final UriInfo uriInfo) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + log.info("OpenProject API - Fetching project details"); + + OpenProjectProjectData project = openProjectService.getProject(); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.projectSerializer.serialize(settings, project, PROJECT_RESPONSE_PARAMS); + } + + @GET + @Path("work-packages") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve work packages", + description = "Returns work packages from the configured OpenProject project.\n\n" + + "Optional filters: status (open/closed), type name.\n\n" + + "Example: openproject/work-packages?status=open&pageSize=25&offset=1") + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = OpenProjectWorkPackageData.class)))) + public String getWorkPackages( + @QueryParam("status") @Parameter(description = "Filter by status: open or closed") final String status, + @QueryParam("type") @Parameter(description = "Filter by type name") final String type, + @QueryParam("pageSize") @DefaultValue("25") @Parameter(description = "Page size") final int pageSize, + @QueryParam("offset") @DefaultValue("1") @Parameter(description = "Page offset") final int offset, + @Context final UriInfo uriInfo) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + log.info("OpenProject API - Fetching work packages (status={}, type={}, pageSize={}, offset={})", status, type, pageSize, offset); + + Collection workPackages = openProjectService.getWorkPackages(status, type, pageSize, offset); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.workPackageSerializer.serialize(settings, workPackages, WORK_PACKAGE_RESPONSE_PARAMS); + } + + @GET + @Path("work-packages/{workPackageId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a single work package by ID", + description = "Returns work package details from OpenProject by its ID.") + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = OpenProjectWorkPackageData.class))) + public String getWorkPackageById( + @PathParam("workPackageId") @Parameter(description = "Work package ID") final Long workPackageId, + @Context final UriInfo uriInfo) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + log.info("OpenProject API - Fetching work package by ID: {}", workPackageId); + + OpenProjectWorkPackageData workPackage = openProjectService.getWorkPackageById(workPackageId); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.workPackageSerializer.serialize(settings, workPackage, WORK_PACKAGE_RESPONSE_PARAMS); + } + + @GET + @Path("summary") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve project status summary", + description = "Returns a summary of all work packages grouped by status, type, and priority.") + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = OpenProjectStatusSummaryData.class))) + public String getStatusSummary(@Context final UriInfo uriInfo) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + log.info("OpenProject API - Fetching status summary"); + + OpenProjectStatusSummaryData summary = openProjectService.getStatusSummary(); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.summarySerializer.serialize(settings, summary, SUMMARY_RESPONSE_PARAMS); + } + + @POST + @Path("work-packages") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Create a new work package", + description = "Creates a new work package in OpenProject.\n\n" + + "Request body: {\"subject\": \"Task title\", \"description\": \"Details\", \"type\": \"Task\"}\n\n" + + "Valid types: Task, Milestone, Feature, Epic, User story, Bug, Incident, Support Ticket, etc.") + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = OpenProjectWorkPackageData.class))) + public String createWorkPackage(@Parameter(hidden = true) final String jsonRequestBody, + @Context final UriInfo uriInfo) { + this.context.authenticatedUser().validateHasCreatePermission(RESOURCE_NAME_FOR_PERMISSIONS); + log.info("OpenProject API - Creating work package"); + + JsonObject request = JsonParser.parseString(jsonRequestBody).getAsJsonObject(); + String subject = request.has("subject") ? request.get("subject").getAsString() : null; + String description = request.has("description") ? request.get("description").getAsString() : null; + String type = request.has("type") ? request.get("type").getAsString() : "Task"; + + if (subject == null || subject.isBlank()) { + throw new IllegalArgumentException("subject is required"); + } + + OpenProjectWorkPackageData created = openProjectService.createWorkPackage(subject, description, type); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.workPackageSerializer.serialize(settings, created, WORK_PACKAGE_RESPONSE_PARAMS); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectProperties.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectProperties.java new file mode 100644 index 000000000..1654cb21d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectProperties.java @@ -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.infrastructure.openproject.service; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "fineract.openproject") +@Getter +@Setter +public class OpenProjectProperties { + + private boolean enabled = true; + private String baseUrl; + private String projectId; + private String apiToken; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java new file mode 100644 index 000000000..3ef36d4ac --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/openproject/service/OpenProjectService.java @@ -0,0 +1,288 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.openproject.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.time.LocalDate; +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; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.core.exception.ResourceNotFoundException; +import org.apache.fineract.infrastructure.openproject.data.OpenProjectProjectData; +import org.apache.fineract.infrastructure.openproject.data.OpenProjectStatusSummaryData; +import org.apache.fineract.infrastructure.openproject.data.OpenProjectWorkPackageData; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class OpenProjectService { + + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private final OkHttpClient httpClient; + private final OpenProjectProperties properties; + private final String credential; + + public OpenProjectService(OkHttpClient httpClient, OpenProjectProperties properties) { + this.httpClient = httpClient; + this.properties = properties; + this.credential = Credentials.basic("apikey", properties.getApiToken()); + } + + public OpenProjectProjectData getProject() { + String url = properties.getBaseUrl() + "/api/v3/projects/" + properties.getProjectId(); + JsonObject json = executeGet(url); + + OpenProjectProjectData data = new OpenProjectProjectData(); + data.setId(json.get("id").getAsLong()); + data.setIdentifier(getStringOrNull(json, "identifier")); + data.setName(getStringOrNull(json, "name")); + data.setActive(json.has("active") && json.get("active").getAsBoolean()); + data.setCreatedAt(getStringOrNull(json, "createdAt")); + data.setUpdatedAt(getStringOrNull(json, "updatedAt")); + + if (json.has("description") && json.get("description").isJsonObject()) { + data.setDescription(getStringOrNull(json.getAsJsonObject("description"), "raw")); + } + return data; + } + + public Collection getWorkPackages(String status, String type, int pageSize, int offset) { + StringBuilder url = new StringBuilder(properties.getBaseUrl()) + .append("/api/v3/projects/").append(properties.getProjectId()) + .append("/work_packages?pageSize=").append(pageSize) + .append("&offset=").append(offset); + + // Build filters + JsonArray filters = new JsonArray(); + if (status != null && !status.isBlank()) { + // Filter by status name — requires status ID lookup, so we use open/closed shorthand + if ("open".equalsIgnoreCase(status)) { + JsonObject f = new JsonObject(); + JsonObject op = new JsonObject(); + op.addProperty("operator", "o"); + op.add("values", new JsonArray()); + f.add("status", op); + filters.add(f); + } else if ("closed".equalsIgnoreCase(status)) { + JsonObject f = new JsonObject(); + JsonObject op = new JsonObject(); + op.addProperty("operator", "c"); + op.add("values", new JsonArray()); + f.add("status", op); + filters.add(f); + } + } + + if (filters.size() > 0) { + url.append("&filters=").append(filters.toString()); + } + + JsonObject json = executeGet(url.toString()); + return parseWorkPackages(json); + } + + public Collection getAllWorkPackages() { + // Fetch all (no status filter) with large page size + String url = properties.getBaseUrl() + + "/api/v3/projects/" + properties.getProjectId() + + "/work_packages?pageSize=200&filters=" + + "[{\"status\":{\"operator\":\"*\",\"values\":[]}}]"; + JsonObject json = executeGet(url); + return parseWorkPackages(json); + } + + public OpenProjectStatusSummaryData getStatusSummary() { + Collection all = getAllWorkPackages(); + + Map byStatus = new LinkedHashMap<>(); + Map byType = new LinkedHashMap<>(); + Map byPriority = new LinkedHashMap<>(); + + for (OpenProjectWorkPackageData wp : all) { + byStatus.merge(wp.getStatusName() != null ? wp.getStatusName() : "Unknown", 1, Integer::sum); + byType.merge(wp.getTypeName() != null ? wp.getTypeName() : "Unknown", 1, Integer::sum); + byPriority.merge(wp.getPriorityName() != null ? wp.getPriorityName() : "Unknown", 1, Integer::sum); + } + + OpenProjectStatusSummaryData summary = new OpenProjectStatusSummaryData(); + summary.setTotalWorkPackages(all.size()); + summary.setByStatus(byStatus); + summary.setByType(byType); + summary.setByPriority(byPriority); + return summary; + } + + public OpenProjectWorkPackageData getWorkPackageById(Long workPackageId) { + String url = properties.getBaseUrl() + "/api/v3/work_packages/" + workPackageId; + JsonObject json = executeGet(url); + return parseSingleWorkPackage(json); + } + + public OpenProjectWorkPackageData createWorkPackage(String subject, String description, String typeName) { + String url = properties.getBaseUrl() + + "/api/v3/projects/" + properties.getProjectId() + "/work_packages"; + + JsonObject body = new JsonObject(); + body.addProperty("subject", subject); + + if (description != null && !description.isBlank()) { + JsonObject desc = new JsonObject(); + desc.addProperty("format", "markdown"); + desc.addProperty("raw", description); + body.add("description", desc); + } + + // Set required custom field: "Last Follow-up Date" (customField8) + body.addProperty("customField8", LocalDate.now().toString()); + + if (typeName != null && !typeName.isBlank()) { + JsonObject links = new JsonObject(); + Long typeId = resolveTypeId(typeName); + if (typeId != null) { + JsonObject typeLink = new JsonObject(); + typeLink.addProperty("href", "/api/v3/types/" + typeId); + links.add("type", typeLink); + } + body.add("_links", links); + } + + JsonObject json = executePost(url, body.toString()); + return parseSingleWorkPackage(json); + } + + private Long resolveTypeId(String typeName) { + String url = properties.getBaseUrl() + + "/api/v3/projects/" + properties.getProjectId() + "/types"; + JsonObject json = executeGet(url); + JsonArray elements = json.getAsJsonObject("_embedded").getAsJsonArray("elements"); + for (JsonElement el : elements) { + JsonObject t = el.getAsJsonObject(); + if (typeName.equalsIgnoreCase(getStringOrNull(t, "name"))) { + return t.get("id").getAsLong(); + } + } + return null; + } + + private Collection parseWorkPackages(JsonObject json) { + Collection result = new ArrayList<>(); + if (!json.has("_embedded")) { + return result; + } + JsonArray elements = json.getAsJsonObject("_embedded").getAsJsonArray("elements"); + for (JsonElement el : elements) { + result.add(parseSingleWorkPackage(el.getAsJsonObject())); + } + return result; + } + + private OpenProjectWorkPackageData parseSingleWorkPackage(JsonObject wp) { + OpenProjectWorkPackageData data = new OpenProjectWorkPackageData(); + data.setId(wp.get("id").getAsLong()); + data.setSubject(getStringOrNull(wp, "subject")); + data.setPercentageDone(wp.has("percentageDone") && !wp.get("percentageDone").isJsonNull() + ? wp.get("percentageDone").getAsInt() : 0); + data.setStartDate(getStringOrNull(wp, "startDate")); + data.setDueDate(getStringOrNull(wp, "dueDate")); + data.setCreatedAt(getStringOrNull(wp, "createdAt")); + data.setUpdatedAt(getStringOrNull(wp, "updatedAt")); + + if (wp.has("description") && wp.get("description").isJsonObject()) { + data.setDescription(getStringOrNull(wp.getAsJsonObject("description"), "raw")); + } + + // Extract linked names from _links + if (wp.has("_links") && wp.get("_links").isJsonObject()) { + JsonObject links = wp.getAsJsonObject("_links"); + data.setTypeName(getLinkTitle(links, "type")); + data.setStatusName(getLinkTitle(links, "status")); + data.setPriorityName(getLinkTitle(links, "priority")); + data.setAssigneeName(getLinkTitle(links, "assignee")); + } + + return data; + } + + private String getLinkTitle(JsonObject links, String key) { + if (links.has(key) && links.get(key).isJsonObject()) { + return getStringOrNull(links.getAsJsonObject(key), "title"); + } + return null; + } + + private JsonObject executeGet(String url) { + Request request = new Request.Builder() + .url(url) + .header("Authorization", credential) + .get() + .build(); + return execute(request); + } + + private JsonObject executePost(String url, String jsonBody) { + Request request = new Request.Builder() + .url(url) + .header("Authorization", credential) + .post(RequestBody.create(jsonBody, JSON)) + .build(); + return execute(request); + } + + private JsonObject execute(Request request) { + try (Response response = httpClient.newCall(request).execute()) { + String body = response.body() != null ? response.body().string() : ""; + if (!response.isSuccessful()) { + log.error("OpenProject API error: {} - {}", response.code(), body); + if (response.code() == 404) { + throw new ResourceNotFoundException("error.msg.openproject.not.found", + "OpenProject resource not found: " + body, new Object[] {}); + } + throw new PlatformDataIntegrityException("error.msg.openproject.api.error", + "OpenProject API returned HTTP " + response.code() + ": " + body); + } + return JsonParser.parseString(body).getAsJsonObject(); + } catch (IOException e) { + log.error("OpenProject API connection error", e); + throw new PlatformDataIntegrityException("error.msg.openproject.connection.failed", + "Failed to connect to OpenProject: " + e.getMessage()); + } + } + + private String getStringOrNull(JsonObject json, String key) { + if (json.has(key) && !json.get(key).isJsonNull()) { + return json.get(key).getAsString(); + } + return null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/api/RbiFeedApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/api/RbiFeedApiResource.java new file mode 100644 index 000000000..90ca792b0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/api/RbiFeedApiResource.java @@ -0,0 +1,152 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.rbifeed.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.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 java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; +import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.rbifeed.data.RbiFeedItemData; +import org.apache.fineract.infrastructure.rbifeed.service.RbiFeedFetchService; +import org.apache.fineract.infrastructure.rbifeed.service.RbiFeedReadPlatformService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.stereotype.Component; + +@Path("/v1/rbi-feeds") +@Component +@Tag(name = "RBI Feeds", description = "RBI RSS feed items fetched from rbi.org.in. Categories: PRESS_RELEASE, NOTIFICATION, SPEECH, TENDER, PUBLICATION") +@RequiredArgsConstructor +@Slf4j +public class RbiFeedApiResource { + + private static final Set RESPONSE_DATA_PARAMETERS = new HashSet<>( + Arrays.asList("id", "title", "description", "link", "pubDate", "category", "fetchedOn", + "openProjectWpId", "openProjectWpStatus", "openProjectWpProgress")); + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "RBI_FEED"; + + private final PlatformSecurityContext context; + private final RbiFeedReadPlatformService readPlatformService; + private final RbiFeedFetchService fetchService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final ApiRequestParameterHelper apiRequestParameterHelper; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve all RBI Feed Items", description = "Returns all RBI RSS feed items, optionally filtered by category.\n\n" + + "Valid categories: PRESS_RELEASE, NOTIFICATION, SPEECH, TENDER, PUBLICATION\n\n" + + "Example Requests:\n\nrbi-feeds\n\nrbi-feeds?category=PRESS_RELEASE") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = RbiFeedItemData.class)))) + public String retrieveAll(@QueryParam("category") @Parameter(description = "Filter by category") final String category, + @Context final UriInfo uriInfo) { + log.info("RBI Feed API - retrieveAll called with category: {}", category); + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final Collection feedItems; + if (category != null && !category.isBlank()) { + log.debug("RBI Feed API - Fetching feeds for category: {}", category); + feedItems = this.readPlatformService.retrieveByCategory(category); + } else { + log.debug("RBI Feed API - Fetching all feeds"); + feedItems = this.readPlatformService.retrieveAll(); + } + + log.info("RBI Feed API - Returning {} feed items", feedItems.size()); + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.toApiJsonSerializer.serialize(settings, feedItems, RESPONSE_DATA_PARAMETERS); + } + + @GET + @Path("{feedItemId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a single RBI Feed Item", description = "Returns a specific RBI RSS feed item by ID.\n\n" + + "Example Requests:\n\nrbi-feeds/1") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = RbiFeedItemData.class))) + public String retrieveOne(@PathParam("feedItemId") @Parameter(description = "feedItemId") final Long feedItemId, + @Context final UriInfo uriInfo) { + log.info("RBI Feed API - retrieveOne called with feedItemId: {}", feedItemId); + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + final RbiFeedItemData feedItem = this.readPlatformService.retrieveOne(feedItemId); + log.debug("RBI Feed API - Found feed item: {}", feedItem != null ? feedItem.getTitle() : "null"); + + final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); + return this.toApiJsonSerializer.serialize(settings, feedItem, RESPONSE_DATA_PARAMETERS); + } + + @POST + @Path("fetch") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Manually trigger RBI feed fetch", description = "Fetches latest RSS feeds from RBI website and stores new items in the database.\n\n" + + "For NOTIFICATION category items, OpenProject work packages are automatically created and linked.") + @ApiResponse(responseCode = "200", description = "OK") + public String fetchFeeds(@Parameter(hidden = true) final String jsonRequestBody) { + log.info("RBI Feed API - Manual fetch triggered"); + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + log.debug("RBI Feed API - User authenticated, starting feed fetch..."); + + try { + int newItems = this.fetchService.fetchAndStoreAllFeeds(); + log.info("RBI Feed API - Fetch completed. {} new items stored.", newItems); + return this.toApiJsonSerializer.serialize(java.util.Map.of("newItemsFetched", newItems)); + } catch (Exception e) { + log.error("RBI Feed API - Error during feed fetch", e); + throw e; + } + } + + @POST + @Path("sync-openproject") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Sync OpenProject work package status", + description = "Updates the OpenProject work package status and progress for all linked NOTIFICATION feed items.") + @ApiResponse(responseCode = "200", description = "OK") + public String syncOpenProjectStatus(@Parameter(hidden = true) final String jsonRequestBody) { + log.info("RBI Feed API - OpenProject status sync triggered"); + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + + int updated = this.fetchService.syncOpenProjectStatus(); + return this.toApiJsonSerializer.serialize(java.util.Map.of("itemsUpdated", updated)); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsConfig.java new file mode 100644 index 000000000..31be440a0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsConfig.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.rbifeed.jobs.fetchrbifeeds; + +import org.apache.fineract.infrastructure.jobs.service.JobName; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class FetchRbiFeedsConfig { + + @Autowired + private JobRepository jobRepository; + @Autowired + private PlatformTransactionManager transactionManager; + + @Bean + protected Step fetchRbiFeedsStep(FetchRbiFeedsTasklet fetchRbiFeedsTasklet) { + return new StepBuilder(JobName.FETCH_RBI_RSS_FEEDS.name(), jobRepository) + .tasklet(fetchRbiFeedsTasklet, transactionManager).build(); + } + + @Bean + public Job fetchRbiFeedsJob(FetchRbiFeedsTasklet fetchRbiFeedsTasklet) { + return new JobBuilder(JobName.FETCH_RBI_RSS_FEEDS.name(), jobRepository) + .start(fetchRbiFeedsStep(fetchRbiFeedsTasklet)).incrementer(new RunIdIncrementer()).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsTasklet.java new file mode 100644 index 000000000..f8833539c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/jobs/fetchrbifeeds/FetchRbiFeedsTasklet.java @@ -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.infrastructure.rbifeed.jobs.fetchrbifeeds; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.rbifeed.service.RbiFeedFetchService; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Slf4j +@Component +public class FetchRbiFeedsTasklet implements Tasklet { + + private final RbiFeedFetchService rbiFeedFetchService; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("Starting RBI RSS feed fetch job..."); + int newItems = rbiFeedFetchService.fetchAndStoreAllFeeds(); + log.info("RBI RSS feed fetch job completed. {} new items stored.", newItems); + return RepeatStatus.FINISHED; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java new file mode 100644 index 000000000..1d97d05d0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedFetchService.java @@ -0,0 +1,269 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.rbifeed.service; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import jakarta.persistence.EntityManager; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.openproject.data.OpenProjectWorkPackageData; +import org.apache.fineract.infrastructure.openproject.service.OpenProjectProperties; +import org.apache.fineract.infrastructure.openproject.service.OpenProjectService; +import org.apache.fineract.infrastructure.rbifeed.domain.RbiFeedItem; +import org.apache.fineract.infrastructure.rbifeed.domain.RbiFeedItemRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RbiFeedFetchService { + + private final RbiFeedItemRepository repository; + private final OpenProjectService openProjectService; + private final OpenProjectProperties openProjectProperties; + private final EntityManager entityManager; + + private static final String NOTIFICATION_CATEGORY = "NOTIFICATION"; + + private static final Map FEED_URLS = new LinkedHashMap<>(); + + static { + FEED_URLS.put("PRESS_RELEASE", "https://rbi.org.in/pressreleases_rss.xml"); + FEED_URLS.put("NOTIFICATION", "https://rbi.org.in/notifications_rss.xml"); + FEED_URLS.put("SPEECH", "https://rbi.org.in/speeches_rss.xml"); + FEED_URLS.put("TENDER", "https://rbi.org.in/tenders_rss.xml"); + FEED_URLS.put("PUBLICATION", "https://rbi.org.in/Publication_rss.xml"); + } + + private static final DateTimeFormatter RBI_DATE_FORMAT = new DateTimeFormatterBuilder() + .appendPattern("EEE, dd MMM yyyy HH:mm:ss") + .optionalStart().appendPattern(" Z").optionalEnd() + .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) + .toFormatter(Locale.ENGLISH); + + @Transactional + public int fetchAndStoreAllFeeds() { + // Clear Hibernate persistence context so existsByLink() queries hit the DB directly. + // This fixes the issue where externally truncating m_rbi_feed_item wouldn't be seen + // due to stale cached entities. + entityManager.clear(); + log.info("RBI FeedFetchService - Starting fetch for all {} feed categories", FEED_URLS.size()); + int totalNewItems = 0; + for (Map.Entry entry : FEED_URLS.entrySet()) { + try { + int count = fetchAndStoreFeed(entry.getKey(), entry.getValue()); + totalNewItems += count; + log.info("RBI FeedFetchService - Category {} completed: {} new items", entry.getKey(), count); + } catch (Exception e) { + log.error("RBI FeedFetchService - Failed to fetch for category: {} from URL: {}", entry.getKey(), entry.getValue(), e); + } + } + log.info("RBI FeedFetchService - All feeds fetched. Total new items: {}", totalNewItems); + + // Create OpenProject work packages for any NOTIFICATION items that don't have one yet + // (e.g. items fetched before OpenProject integration was enabled) + if (openProjectProperties.isEnabled()) { + List unlinkedNotifications = repository.findByCategoryAndOpenProjectWpIdIsNull(NOTIFICATION_CATEGORY); + if (!unlinkedNotifications.isEmpty()) { + log.info("RBI FeedFetchService - Found {} existing NOTIFICATION items without OpenProject WP, creating now", + unlinkedNotifications.size()); + createOpenProjectWorkPackages(unlinkedNotifications); + } + } + + return totalNewItems; + } + + private int fetchAndStoreFeed(final String category, final String url) throws Exception { + log.debug("RBI FeedFetchService - Fetching RSS from URL: {}", url); + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)) + .header("User-Agent", "Fineract-CBS/1.0").GET().build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + log.debug("RBI FeedFetchService - HTTP response status: {} for {}", response.statusCode(), url); + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(response.body()); + + NodeList items = doc.getElementsByTagName("item"); + log.debug("RBI FeedFetchService - Parsed {} items from RSS for category: {}", items.getLength(), category); + List newItems = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + + for (int i = 0; i < items.getLength(); i++) { + Element item = (Element) items.item(i); + String title = getElementText(item, "title"); + String description = getElementText(item, "description"); + String link = getElementText(item, "link"); + String pubDateStr = getElementText(item, "pubDate"); + + if (link == null) { + log.warn("RBI FeedFetchService - Skipping item with null link, title: {}", title); + continue; + } + if (repository.existsByLink(link)) { + log.trace("RBI FeedFetchService - Skipping existing item: {}", link); + continue; + } + + LocalDateTime pubDate = null; + if (pubDateStr != null && !pubDateStr.isBlank()) { + try { + pubDate = LocalDateTime.parse(pubDateStr.trim(), RBI_DATE_FORMAT); + } catch (Exception e) { + log.warn("RBI FeedFetchService - Could not parse date '{}' for: {}", pubDateStr, title); + } + } + + RbiFeedItem feedItem = new RbiFeedItem().setTitle(title).setDescription(description).setLink(link).setPubDate(pubDate) + .setCategory(category).setFetchedOn(now); + newItems.add(feedItem); + log.debug("RBI FeedFetchService - New item queued: {}", title); + } + + if (!newItems.isEmpty()) { + repository.saveAll(newItems); + log.info("RBI FeedFetchService - Stored {} new items for category: {}", newItems.size(), category); + + // Create OpenProject work packages for NOTIFICATION items only + if (NOTIFICATION_CATEGORY.equals(category) && openProjectProperties.isEnabled()) { + createOpenProjectWorkPackages(newItems); + } + } else { + log.debug("RBI FeedFetchService - No new items for category: {}", category); + } + return newItems.size(); + } + + /** + * Creates OpenProject work packages for newly fetched NOTIFICATION feed items. + * Links each feed item to its work package via openProjectWpId. + * Uniqueness is guaranteed because this method is only called for items that + * just passed the existsByLink check (new items only). + */ + private void createOpenProjectWorkPackages(final List newNotificationItems) { + log.info("RBI FeedFetchService - Creating {} OpenProject work packages for NOTIFICATION items", newNotificationItems.size()); + for (RbiFeedItem feedItem : newNotificationItems) { + try { + String wpDescription = buildWorkPackageDescription(feedItem); + OpenProjectWorkPackageData wp = openProjectService.createWorkPackage( + "[RBI Notification] " + feedItem.getTitle(), + wpDescription, + "Task" + ); + feedItem.setOpenProjectWpId(wp.getId()); + feedItem.setOpenProjectWpStatus(wp.getStatusName()); + feedItem.setOpenProjectWpProgress(wp.getPercentageDone()); + repository.save(feedItem); + log.info("RBI FeedFetchService - Created OpenProject WP #{} for feed item: {}", wp.getId(), feedItem.getTitle()); + } catch (Exception e) { + log.error("RBI FeedFetchService - Failed to create OpenProject WP for: {}", feedItem.getTitle(), e); + } + } + } + + private String buildWorkPackageDescription(final RbiFeedItem feedItem) { + StringBuilder sb = new StringBuilder(); + sb.append("## RBI Notification\n\n"); + sb.append("**Title:** ").append(feedItem.getTitle()).append("\n\n"); + if (feedItem.getDescription() != null && !feedItem.getDescription().isBlank()) { + sb.append("**Description:** ").append(feedItem.getDescription()).append("\n\n"); + } + if (feedItem.getPubDate() != null) { + sb.append("**Published:** ").append(feedItem.getPubDate().toLocalDate()).append("\n\n"); + } + sb.append("**RBI Link:** ").append(feedItem.getLink()).append("\n\n"); + sb.append("---\n*Auto-created by CBS Fineract RBI Feed Integration*"); + return sb.toString(); + } + + /** + * Syncs OpenProject work package status/progress back to linked RBI feed items. + * Call this to update progress tracking for all linked NOTIFICATION items. + */ + @Transactional + public int syncOpenProjectStatus() { + if (!openProjectProperties.isEnabled()) { + return 0; + } + List linkedItems = repository.findByCategoryAndOpenProjectWpIdIsNotNull(NOTIFICATION_CATEGORY); + log.info("RBI FeedFetchService - Syncing OpenProject status for {} linked items", linkedItems.size()); + int updated = 0; + for (RbiFeedItem feedItem : linkedItems) { + try { + var allWps = openProjectService.getAllWorkPackages(); + for (var wp : allWps) { + if (wp.getId().equals(feedItem.getOpenProjectWpId())) { + boolean changed = false; + if (wp.getStatusName() != null && !wp.getStatusName().equals(feedItem.getOpenProjectWpStatus())) { + feedItem.setOpenProjectWpStatus(wp.getStatusName()); + changed = true; + } + if (wp.getPercentageDone() != null && !wp.getPercentageDone().equals(feedItem.getOpenProjectWpProgress())) { + feedItem.setOpenProjectWpProgress(wp.getPercentageDone()); + changed = true; + } + if (changed) { + repository.save(feedItem); + updated++; + log.debug("RBI FeedFetchService - Updated WP #{} status={} progress={}%", + wp.getId(), wp.getStatusName(), wp.getPercentageDone()); + } + break; + } + } + } catch (Exception e) { + log.error("RBI FeedFetchService - Failed to sync status for WP #{}", feedItem.getOpenProjectWpId(), e); + } + } + log.info("RBI FeedFetchService - Synced {} items", updated); + return updated; + } + + private String getElementText(Element parent, String tagName) { + NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() > 0) { + return nodes.item(0).getTextContent(); + } + return null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformService.java new file mode 100644 index 000000000..688174313 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformService.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.rbifeed.service; + +import java.util.Collection; +import org.apache.fineract.infrastructure.rbifeed.data.RbiFeedItemData; + +public interface RbiFeedReadPlatformService { + + Collection retrieveAll(); + + Collection retrieveByCategory(String category); + + RbiFeedItemData retrieveOne(Long feedItemId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformServiceImpl.java new file mode 100644 index 000000000..040e10e48 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/rbifeed/service/RbiFeedReadPlatformServiceImpl.java @@ -0,0 +1,96 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.rbifeed.service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.Collection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.rbifeed.data.RbiFeedItemData; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RbiFeedReadPlatformServiceImpl implements RbiFeedReadPlatformService { + + private final JdbcTemplate jdbcTemplate; + + private static final class RbiFeedItemRowMapper implements RowMapper { + + public String schema() { + return "f.id, f.title, f.description, f.link, f.pub_date, f.category, f.fetched_on," + + " f.openproject_wp_id, f.openproject_wp_status, f.openproject_wp_progress" + + " from m_rbi_feed_item f"; + } + + @Override + public RbiFeedItemData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException { + final Long id = rs.getLong("id"); + final String title = rs.getString("title"); + final String description = rs.getString("description"); + final String link = rs.getString("link"); + final Timestamp pubDateTs = rs.getTimestamp("pub_date"); + final LocalDateTime pubDate = pubDateTs != null ? pubDateTs.toLocalDateTime() : null; + final String category = rs.getString("category"); + final Timestamp fetchedOnTs = rs.getTimestamp("fetched_on"); + final LocalDateTime fetchedOn = fetchedOnTs != null ? fetchedOnTs.toLocalDateTime() : null; + final long wpIdRaw = rs.getLong("openproject_wp_id"); + final Long openProjectWpId = rs.wasNull() ? null : wpIdRaw; + final String openProjectWpStatus = rs.getString("openproject_wp_status"); + final int wpProgressRaw = rs.getInt("openproject_wp_progress"); + final Integer openProjectWpProgress = rs.wasNull() ? null : wpProgressRaw; + return RbiFeedItemData.instance(id, title, description, link, pubDate, category, fetchedOn, + openProjectWpId, openProjectWpStatus, openProjectWpProgress); + } + } + + @Override + public Collection retrieveAll() { + log.info("RBI Feed ReadService - retrieveAll"); + final RbiFeedItemRowMapper rm = new RbiFeedItemRowMapper(); + final String sql = "select " + rm.schema() + " order by f.pub_date desc"; + final Collection results = this.jdbcTemplate.query(sql, rm); + log.debug("RBI Feed ReadService - retrieveAll returned {} items", results.size()); + return results; + } + + @Override + public Collection retrieveByCategory(final String category) { + log.info("RBI Feed ReadService - retrieveByCategory: {}", category); + final RbiFeedItemRowMapper rm = new RbiFeedItemRowMapper(); + final String sql = "select " + rm.schema() + " where f.category = ? order by f.pub_date desc"; + final Collection results = this.jdbcTemplate.query(sql, rm, category); + log.debug("RBI Feed ReadService - retrieveByCategory returned {} items for {}", results.size(), category); + return results; + } + + @Override + public RbiFeedItemData retrieveOne(final Long feedItemId) { + log.info("RBI Feed ReadService - retrieveOne: {}", feedItemId); + final RbiFeedItemRowMapper rm = new RbiFeedItemRowMapper(); + final String sql = "select " + rm.schema() + " where f.id = ?"; + return this.jdbcTemplate.queryForObject(sql, rm, feedItemId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java index 98562a56d..929a74300 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/charge/service/ChargeReadPlatformServiceImpl.java @@ -135,6 +135,10 @@ public class ChargeReadPlatformServiceImpl implements ChargeReadPlatformService final List shareChargeCalculationTypeOptions = this.chargeDropdownReadPlatformService .retrieveSharesCalculationTypes(); final List shareChargeTimeTypeOptions = this.chargeDropdownReadPlatformService.retrieveSharesCollectionTimeTypes(); + final List currentAccountChargeCalculationTypeOptions = this.chargeDropdownReadPlatformService + .retrieveCurrentAccountCalculationTypes(); + final List currentAccountChargeTimeTypeOptions = this.chargeDropdownReadPlatformService + .retrieveCurrentAccountCollectionTimeTypes(); final Collection taxGroupOptions = this.taxReadPlatformService.retrieveTaxGroupsForLookUp(); final String accountMappingForChargeConfig = this.configurationDomainServiceJpa.getAccountMappingForCharge(); final List expenseAccountOptions = this.accountingDropdownReadPlatformService.retrieveExpenseAccountOptions(); @@ -150,6 +154,8 @@ public class ChargeReadPlatformServiceImpl implements ChargeReadPlatformService .clientChargeTimeTypeOptions(clientChargeTimeTypeOptions).feeFrequencyOptions(feeFrequencyOptions) .incomeOrLiabilityAccountOptions(incomeOrLiabilityAccountOptions).taxGroupOptions(taxGroupOptions) .shareChargeCalculationTypeOptions(shareChargeCalculationTypeOptions).shareChargeTimeTypeOptions(shareChargeTimeTypeOptions) + .currentAccountChargeCalculationTypeOptions(currentAccountChargeCalculationTypeOptions) + .currentAccountChargeTimeTypeOptions(currentAccountChargeTimeTypeOptions) .accountMappingForChargeConfig(accountMappingForChargeConfig).expenseAccountOptions(expenseAccountOptions) .assetAccountOptions(assetAccountOptions).build(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java new file mode 100644 index 000000000..19ceafc4b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java @@ -0,0 +1,267 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.data.ClientKycApiConstants; +import org.apache.fineract.portfolio.client.data.ClientKycData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.apache.fineract.portfolio.client.data.ClientKycRequest; +import org.apache.fineract.portfolio.client.service.ClientKycReadPlatformService; +import org.springframework.stereotype.Component; + +/** + * REST endpoints for client KYC (Know Your Customer) as mandated by RBI Master Direction on KYC. + * + *
+ *   POST   /v1/clients/{clientId}/kyc                                      – Initiate KYC
+ *   GET    /v1/clients/{clientId}/kyc                                      – Get KYC record for a client
+ *   GET    /v1/clients/{clientId}/kyc/{kycId}                              – Get KYC record by id
+ *   PUT    /v1/clients/{clientId}/kyc/{kycId}                              – Update KYC documents
+ *   POST   /v1/clients/{clientId}/kyc/{kycId}?command=aadhaar-generate-otp – Step 1: send Aadhaar OTP
+ *   POST   /v1/clients/{clientId}/kyc/{kycId}?command=verify-pan           – Verify PAN via LincPay
+ *   POST   /v1/clients/{clientId}/kyc/{kycId}?command=verify-aadhaar       – Step 2: verify Aadhaar OTP
+ *   POST   /v1/clients/{clientId}/kyc/{kycId}?command=verify-gstin         – Verify GSTIN via LincPay
+ *   POST   /v1/clients/{clientId}/kyc/{kycId}?command=verify               – Verify all in one shot
+ *   POST   /v1/clients/{clientId}/kyc/{kycId}?command=reject               – Reject KYC
+ * 
+ */ +@Path("/v1/clients/{clientId}/kyc") +@Component +@Tag(name = "Client KYC", + description = "Client KYC (Know Your Customer) module compliant with RBI Master Direction on KYC. " + + "Supports PAN, Aadhaar and GSTIN verification via pluggable 3rd-party APIs.") +@RequiredArgsConstructor +public class ClientKycApiResource { + + private static final Logger LOG = LoggerFactory.getLogger(ClientKycApiResource.class); + private static final String RESOURCE_NAME_FOR_PERMISSIONS = ClientKycApiConstants.RESOURCE_NAME; + + private final PlatformSecurityContext context; + private final ClientKycReadPlatformService readPlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + // ───────────────────────────────────────────────────────────────────────── + // INITIATE KYC – POST /v1/clients/{clientId}/kyc + // ───────────────────────────────────────────────────────────────────────── + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Initiate KYC for a Client", + description = "Creates a new KYC record for the client with status **PENDING**.\n\n" + + "**RBI Requirements:**\n" + + "- PAN is mandatory for all customers.\n" + + "- Aadhaar is mandatory for INDIVIDUAL clients.\n" + + "- GSTIN is mandatory for NON_INDIVIDUAL (business) clients.\n" + + "- Aadhaar must be stored **masked** (first 8 digits replaced with X).") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientKycRequest.class))) + @ApiResponse(responseCode = "200", description = "KYC initiated successfully", + content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) + @ApiResponse(responseCode = "400", description = "Invalid request / validation error") + @ApiResponse(responseCode = "409", description = "KYC already exists for this client") + public CommandProcessingResult initiateKyc( + @PathParam("clientId") @Parameter(description = "Client identifier") final Long clientId, + @Parameter(hidden = true) final ClientKycRequest clientKycRequest) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .initiateClientKyc(clientId) + .withJson(toApiJsonSerializer.serialize(clientKycRequest)) + .build(); + + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + // ───────────────────────────────────────────────────────────────────────── + // GET KYC for client – GET /v1/clients/{clientId}/kyc + // ───────────────────────────────────────────────────────────────────────── + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve KYC record for a Client", + description = "Returns the KYC record associated with the given client.") + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = ClientKycData.class))) + @ApiResponse(responseCode = "404", description = "KYC record not found") + public String retrieveClientKycByClientId( + @PathParam("clientId") @Parameter(description = "Client identifier") final Long clientId) { + + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final ClientKycData data = readPlatformService.retrieveClientKycByClientId(clientId); + return toApiJsonSerializer.serialize(data); + } + + // ───────────────────────────────────────────────────────────────────────── + // GET KYC by ID – GET /v1/clients/{clientId}/kyc/{kycId} + // ───────────────────────────────────────────────────────────────────────── + + @GET + @Path("{kycId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve a KYC record by ID", + description = "Returns the KYC record identified by {kycId} for the given client.") + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = ClientKycData.class))) + public String retrieveClientKyc( + @PathParam("clientId") @Parameter(description = "Client identifier") final Long clientId, + @PathParam("kycId") @Parameter(description = "KYC record identifier") final Long kycId) { + + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final ClientKycData data = readPlatformService.retrieveClientKyc(kycId); + return toApiJsonSerializer.serialize(data); + } + + // ───────────────────────────────────────────────────────────────────────── + // UPDATE KYC – PUT /v1/clients/{clientId}/kyc/{kycId} + // ───────────────────────────────────────────────────────────────────────── + + @PUT + @Path("{kycId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Update KYC document details", + description = "Updates PAN, Aadhaar, GSTIN or risk category on an existing KYC record.\n" + + "Transitions the status to **IN_PROGRESS** if documents are changed.\n" + + "An already **VERIFIED** KYC cannot be updated.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientKycRequest.class))) + @ApiResponse(responseCode = "200", description = "KYC updated", + content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) + public CommandProcessingResult updateClientKyc( + @PathParam("clientId") @Parameter(description = "Client identifier") final Long clientId, + @PathParam("kycId") @Parameter(description = "KYC record identifier") final Long kycId, + @Parameter(hidden = true) final ClientKycRequest clientKycRequest) { + + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .updateClientKyc(clientId, kycId) + .withJson(toApiJsonSerializer.serialize(clientKycRequest)) + .build(); + + return commandsSourceWritePlatformService.logCommandSource(commandRequest); + } + + // ───────────────────────────────────────────────────────────────────────── + // VERIFY / REJECT – POST /v1/clients/{clientId}/kyc/{kycId}?command= + // ───────────────────────────────────────────────────────────────────────── + + @POST + @Path("{kycId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation( + summary = "Execute a KYC state-change command", + description = "Supported commands:\n\n" + + "### Aadhaar e-KYC (2-step)\n" + + "- **aadhaar-generate-otp** – Step 1: send the client's full 12-digit Aadhaar (`aadhaarNumber`) " + + "to trigger an OTP on their registered mobile via LincPay. " + + "The full Aadhaar is **never stored**. " + + "Response includes `aadhaarRefNumber` required for step 2.\n" + + "- **verify-aadhaar** – Step 2: verify the OTP. " + + "Supply `aadhaarRefNumber` (from step 1) and `aadhaarOtp` (entered by client). " + + "Marks `aadhaarVerified = true` on success. KYC is **not** rejected on failure; " + + "the UI can display the error and let the user retry.\n\n" + + "### Individual verifications\n" + + "- **verify-pan** – Validate the stored PAN against Income Tax Dept via LincPay. " + + "No request body needed. Marks `panVerified = true` on success.\n" + + "- **verify-gstin** – Validate the stored GSTIN against the GST Common Portal via LincPay. " + + "No request body needed. Marks `gstinVerified = true` on success.\n\n" + + "### Batch verification (legacy / all-in-one)\n" + + "- **verify** – Trigger one or more verifications in a single call:\n" + + " - `verifyPan=true` → PAN validated.\n" + + " - `verifyAadhaar=true` → Aadhaar OTP verified (requires `aadhaarRefNumber` + `aadhaarOtp`).\n" + + " - `verifyGstin=true` → GSTIN validated.\n" + + " If all pass, status moves to **VERIFIED**. On any failure, KYC is **REJECTED**.\n\n" + + "### Rejection\n" + + "- **reject** – Manually reject the KYC. `remarks` (reason) is mandatory.") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = ClientKycRequest.class))) + @ApiResponse(responseCode = "200", description = "Command executed", + content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) + @ApiResponse(responseCode = "400", description = "Invalid command or state transition not allowed") + public CommandProcessingResult handleKycCommand( + @PathParam("clientId") @Parameter(description = "Client identifier") final Long clientId, + @PathParam("kycId") @Parameter(description = "KYC record identifier") final Long kycId, + @QueryParam("command") @Parameter( + description = "Command: aadhaar-generate-otp | verify-pan | verify-aadhaar | verify-gstin | verify | reject") + final String command, + @Parameter(hidden = true) final ClientKycRequest clientKycRequest) { + + final String json = toApiJsonSerializer.serialize(clientKycRequest); + + LOG.info("KYC command received: clientId={}, kycId={}, command={}, requestBody={}", clientId, kycId, command, json); + + final CommandWrapper commandRequest; + if ("aadhaar-generate-otp".equalsIgnoreCase(command)) { + commandRequest = new CommandWrapperBuilder() + .generateAadhaarOtpClientKyc(clientId, kycId).withJson(json).build(); + } else if ("verify-pan".equalsIgnoreCase(command)) { + commandRequest = new CommandWrapperBuilder() + .verifyPanClientKyc(clientId, kycId).withJson(json).build(); + } else if ("verify-aadhaar".equalsIgnoreCase(command)) { + commandRequest = new CommandWrapperBuilder() + .verifyAadhaarClientKyc(clientId, kycId).withJson(json).build(); + } else if ("verify-gstin".equalsIgnoreCase(command)) { + commandRequest = new CommandWrapperBuilder() + .verifyGstinClientKyc(clientId, kycId).withJson(json).build(); + } else if ("verify".equalsIgnoreCase(command)) { + commandRequest = new CommandWrapperBuilder().verifyClientKyc(clientId, kycId).withJson(json).build(); + } else if ("reject".equalsIgnoreCase(command)) { + commandRequest = new CommandWrapperBuilder().rejectClientKyc(clientId, kycId).withJson(json).build(); + } else { + throw new org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException( + "command", command, + "aadhaar-generate-otp", "verify-pan", "verify-aadhaar", "verify-gstin", "verify", "reject"); + } + + try { + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + LOG.info("KYC command '{}' completed successfully for clientId={}, kycId={}", command, clientId, kycId); + return result; + } catch (final Exception e) { + LOG.error("KYC command '{}' failed for clientId={}, kycId={}: {}", command, clientId, kycId, e.getMessage(), e); + throw e; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java new file mode 100644 index 000000000..59553fade --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/command/ClientKycCommand.java @@ -0,0 +1,220 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.command; + +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.portfolio.client.data.ClientKycApiConstants; + +/** + * Immutable command object for KYC operations. Validation is performed at + * deserializer stage; this class adds domain-level cross-field checks. + */ +public class ClientKycCommand { + + private final Integer kycType; + private final Integer riskCategory; + private final String panNumber; + private final String aadhaarNumberMasked; + private final String gstinNumber; + private final String remarks; + private final String ckycNumber; + private final Boolean verifyPan; + private final Boolean verifyAadhaar; + private final Boolean verifyGstin; + + // ── Aadhaar OTP flow ────────────────────────────────────────────────────── + /** Full Aadhaar – used ONLY for OTP generation; never stored. */ + private final String aadhaarNumber; + /** refNumber returned by LincPay generate-otp; supplied for verify-otp. */ + private final String aadhaarRefNumber; + /** OTP entered by the client. */ + private final String aadhaarOtp; + + public ClientKycCommand(final Integer kycType, final Integer riskCategory, final String panNumber, + final String aadhaarNumberMasked, final String gstinNumber, final String remarks, + final String ckycNumber, final Boolean verifyPan, final Boolean verifyAadhaar, + final Boolean verifyGstin, final String aadhaarNumber, final String aadhaarRefNumber, + final String aadhaarOtp) { + this.kycType = kycType; + this.riskCategory = riskCategory; + this.panNumber = panNumber; + this.aadhaarNumberMasked = aadhaarNumberMasked; + this.gstinNumber = gstinNumber; + this.remarks = remarks; + this.ckycNumber = ckycNumber; + this.verifyPan = verifyPan; + this.verifyAadhaar = verifyAadhaar; + this.verifyGstin = verifyGstin; + this.aadhaarNumber = aadhaarNumber; + this.aadhaarRefNumber = aadhaarRefNumber; + this.aadhaarOtp = aadhaarOtp; + } + + public void validateForInitiate() { + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME); + + v.reset().parameter(ClientKycApiConstants.kycTypeParam).value(kycType).notNull().inMinMaxRange(1, 2); + v.reset().parameter(ClientKycApiConstants.riskCategoryParam).value(riskCategory).notNull().inMinMaxRange(1, 3); + + // PAN is mandatory as per RBI KYC guidelines + v.reset().parameter(ClientKycApiConstants.panNumberParam).value(panNumber).notBlank() + .notExceedingLengthOf(10) + .matchesRegularExpression(ClientKycApiConstants.PAN_REGEX, + "PAN must be in format: 5 uppercase letters + 4 digits + 1 uppercase letter (e.g. ABCDE1234F)"); + + // Aadhaar – mandatory for individual + if (kycType != null && kycType == 1) { + v.reset().parameter(ClientKycApiConstants.aadhaarNumberMaskedParam).value(aadhaarNumberMasked).notBlank() + .notExceedingLengthOf(12) + .matchesRegularExpression(ClientKycApiConstants.AADHAAR_MASKED_REGEX, + "Aadhaar masked number must be 12 characters (first 8 masked, last 4 digits visible)"); + } + + // GSTIN – mandatory for non-individual + if (kycType != null && kycType == 2) { + v.reset().parameter(ClientKycApiConstants.gstinNumberParam).value(gstinNumber).notBlank() + .notExceedingLengthOf(15) + .matchesRegularExpression(ClientKycApiConstants.GSTIN_REGEX, + "GSTIN must be a valid 15-character GST Identification Number"); + } + + throwExceptionIfValidationWarningsExist(errors); + } + + public void validateForUpdate() { + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME); + + if (panNumber != null) { + v.reset().parameter(ClientKycApiConstants.panNumberParam).value(panNumber).notBlank() + .notExceedingLengthOf(10) + .matchesRegularExpression(ClientKycApiConstants.PAN_REGEX, + "PAN must be in format: 5 uppercase letters + 4 digits + 1 uppercase letter"); + } + + if (aadhaarNumberMasked != null) { + v.reset().parameter(ClientKycApiConstants.aadhaarNumberMaskedParam).value(aadhaarNumberMasked) + .notExceedingLengthOf(12) + .matchesRegularExpression(ClientKycApiConstants.AADHAAR_MASKED_REGEX, + "Aadhaar masked number must be 12 characters"); + } + + if (gstinNumber != null) { + v.reset().parameter(ClientKycApiConstants.gstinNumberParam).value(gstinNumber) + .notExceedingLengthOf(15) + .matchesRegularExpression(ClientKycApiConstants.GSTIN_REGEX, + "GSTIN must be a valid 15-character GST Identification Number"); + } + + if (riskCategory != null) { + v.reset().parameter(ClientKycApiConstants.riskCategoryParam).value(riskCategory).inMinMaxRange(1, 3); + } + + throwExceptionIfValidationWarningsExist(errors); + } + + public void validateForAadhaarGenerateOtp() { + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME); + + v.reset().parameter(ClientKycApiConstants.aadhaarNumberParam).value(aadhaarNumber).notBlank() + .notExceedingLengthOf(12) + .matchesRegularExpression("^[0-9]{12}$", + "Aadhaar number must be exactly 12 digits"); + + throwExceptionIfValidationWarningsExist(errors); + } + + public void validateForVerify() { + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME); + + if (ckycNumber != null) { + v.reset().parameter(ClientKycApiConstants.ckycNumberParam).value(ckycNumber).notExceedingLengthOf(14); + } + + // If verifyAadhaar=true, refNumber and OTP are mandatory + if (Boolean.TRUE.equals(verifyAadhaar)) { + v.reset().parameter(ClientKycApiConstants.aadhaarRefNumberParam).value(aadhaarRefNumber).notBlank(); + v.reset().parameter(ClientKycApiConstants.aadhaarOtpParam).value(aadhaarOtp).notBlank(); + } + + throwExceptionIfValidationWarningsExist(errors); + } + + /** verify-pan command: PAN is read from the existing KYC record – no required body fields. */ + public void validateForVerifyPan() { + // PAN is already stored on the KYC entity; nothing to validate in the command body. + } + + /** verify-aadhaar command: refNumber and OTP are required. */ + public void validateForVerifyAadhaar() { + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME); + + v.reset().parameter(ClientKycApiConstants.aadhaarRefNumberParam).value(aadhaarRefNumber).notBlank(); + v.reset().parameter(ClientKycApiConstants.aadhaarOtpParam).value(aadhaarOtp).notBlank(); + + throwExceptionIfValidationWarningsExist(errors); + } + + /** verify-gstin command: GSTIN is read from the existing KYC record – no required body fields. */ + public void validateForVerifyGstin() { + // GSTIN is already stored on the KYC entity; nothing to validate in the command body. + } + + public void validateForReject() { + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME); + + v.reset().parameter(ClientKycApiConstants.remarksParam).value(remarks).notBlank().notExceedingLengthOf(500); + + throwExceptionIfValidationWarningsExist(errors); + } + + private void throwExceptionIfValidationWarningsExist(final List errors) { + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException(errors); + } + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + public Integer getKycType() { return kycType; } + public Integer getRiskCategory() { return riskCategory; } + public String getPanNumber() { return panNumber; } + public String getAadhaarNumberMasked() { return aadhaarNumberMasked; } + public String getGstinNumber() { return gstinNumber; } + public String getRemarks() { return remarks; } + public String getCkycNumber() { return ckycNumber; } + public Boolean getVerifyPan() { return verifyPan; } + public Boolean getVerifyAadhaar() { return verifyAadhaar; } + public Boolean getVerifyGstin() { return verifyGstin; } + /** Full Aadhaar – used ONLY for OTP generation; never stored. */ + public String getAadhaarNumber() { return aadhaarNumber; } + /** refNumber returned by LincPay generate-otp. */ + public String getAadhaarRefNumber() { return aadhaarRefNumber; } + /** OTP entered by the client. */ + public String getAadhaarOtp() { return aadhaarOtp; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java index 330afe3e6..bc727f25a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java @@ -30,7 +30,7 @@ public class ClientApiCollectionConstants extends ClientApiConstants { emailAddressParamName, firstnameParamName, middlenameParamName, lastnameParamName, fullnameParamName, officeIdParamName, activeParamName, activationDateParamName, staffIdParamName, submittedOnDateParamName, savingsProductIdParamName, dateOfBirthParamName, genderIdParamName, clientTypeIdParamName, clientClassificationIdParamName, - clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName)); + clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName)); protected static final Set CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java new file mode 100644 index 000000000..f5d346fbb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java @@ -0,0 +1,89 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.data; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public final class ClientKycApiConstants { + + private ClientKycApiConstants() {} + + public static final String RESOURCE_NAME = "CLIENTKYC"; + + // ── Request parameters ──────────────────────────────────────────────────── + public static final String kycTypeParam = "kycType"; + public static final String riskCategoryParam = "riskCategory"; + public static final String panNumberParam = "panNumber"; + public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked"; + public static final String gstinNumberParam = "gstinNumber"; + public static final String remarksParam = "remarks"; + public static final String ckycNumberParam = "ckycNumber"; + public static final String verifyPanParam = "verifyPan"; + public static final String verifyAadhaarParam = "verifyAadhaar"; + public static final String verifyGstinParam = "verifyGstin"; + + // ── Aadhaar OTP flow parameters ─────────────────────────────────────────── + /** Full 12-digit Aadhaar – used ONLY for OTP generation; never stored per UIDAI directive. */ + public static final String aadhaarNumberParam = "aadhaarNumber"; + /** refNumber returned by LincPay generate-otp; supplied back with the OTP for verify-otp. */ + public static final String aadhaarRefNumberParam = "aadhaarRefNumber"; + /** One-time password entered by the client – required for the verify-otp step. */ + public static final String aadhaarOtpParam = "aadhaarOtp"; + + // ── Supported parameters for initiate/update ────────────────────────────── + public static final Set INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( + kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, + gstinNumberParam, remarksParam, "locale")); + + public static final Set UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( + panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, + riskCategoryParam, remarksParam, "locale")); + + /** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */ + public static final Set VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList( + ckycNumberParam, remarksParam, verifyPanParam, verifyAadhaarParam, verifyGstinParam, + aadhaarRefNumberParam, aadhaarOtpParam)); + + /** aadhaar-generate-otp command: only requires the client's full Aadhaar number. */ + public static final Set AADHAAR_GENERATE_OTP_REQUEST_PARAMS = new HashSet<>( + Arrays.asList(aadhaarNumberParam)); + + /** verify-pan command: PAN is already stored; no extra body params required. */ + public static final Set VERIFY_PAN_REQUEST_PARAMS = new HashSet<>(); + + /** verify-aadhaar command: requires the refNumber from the generate-otp step and the OTP entered by the client. */ + public static final Set VERIFY_AADHAAR_REQUEST_PARAMS = new HashSet<>( + Arrays.asList(aadhaarRefNumberParam, aadhaarOtpParam)); + + /** verify-gstin command: GSTIN is already stored; no extra body params required. */ + public static final Set VERIFY_GSTIN_REQUEST_PARAMS = new HashSet<>(); + + public static final Set REJECT_REQUEST_PARAMS = new HashSet<>(Arrays.asList(remarksParam)); + + // ── PAN regex: 5 uppercase letters + 4 digits + 1 uppercase letter ──────── + public static final String PAN_REGEX = "^[A-Z]{5}[0-9]{4}[A-Z]{1}$"; + + // ── Aadhaar masked: stored as XXXX-XXXX-NNNN or 12 digits ──────────────── + public static final String AADHAAR_MASKED_REGEX = "^[Xx0-9]{8}[0-9]{4}$"; + + // ── GSTIN: 2-digit state + 10-char PAN + 1 entity + Z + check digit ────── + public static final String GSTIN_REGEX = "^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$"; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java new file mode 100644 index 000000000..b3401952b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java @@ -0,0 +1,72 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.data; + +import java.time.LocalDate; +import lombok.Builder; +import lombok.Getter; + +/** + * Read-only DTO returned by the KYC read-service and serialised to JSON by the API layer. + */ +@Getter +@Builder +public final class ClientKycData { + + private final Long id; + private final Long clientId; + private final String clientDisplayName; + + // ── Status & type ───────────────────────────────────────────────────────── + private final Integer kycStatus; + private final String kycStatusCode; + private final Integer kycType; + private final String kycTypeCode; + private final Integer riskCategory; + private final String riskCategoryCode; + + // ── PAN ─────────────────────────────────────────────────────────────────── + private final String panNumber; + private final Boolean panVerified; + private final LocalDate panVerifiedOn; + private final String panFullName; + private final String panAadhaarSeedingStatus; + private final String panCategory; + private final String panStatus; + + // ── Aadhaar ─────────────────────────────────────────────────────────────── + private final String aadhaarNumberMasked; + private final Boolean aadhaarVerified; + private final LocalDate aadhaarVerifiedOn; + + // ── GSTIN ───────────────────────────────────────────────────────────────── + private final String gstinNumber; + private final Boolean gstinVerified; + private final LocalDate gstinVerifiedOn; + + // ── CKYCRR ──────────────────────────────────────────────────────────────── + private final String ckycNumber; + + // ── Lifecycle ───────────────────────────────────────────────────────────── + private final LocalDate initiatedOn; + private final LocalDate verifiedOn; + private final LocalDate rejectedOn; + private final LocalDate expiryDate; + private final String remarks; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java new file mode 100644 index 000000000..23a3e23a9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.data; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * Inbound request body DTO for KYC initiation and update endpoints. + * Serialised from JSON by the JAX-RS layer and then forwarded via {@link org.apache.fineract.commands.service.CommandWrapperBuilder}. + */ +@Data +public class ClientKycRequest { + + @Schema(description = "Locale for numeric parameter parsing (e.g. 'en')", example = "en", requiredMode = Schema.RequiredMode.REQUIRED) + private String locale; + + @Schema(description = "KYC type: 1 = INDIVIDUAL, 2 = NON_INDIVIDUAL", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer kycType; + + @Schema(description = "Risk category: 1 = LOW (re-KYC 10yr), 2 = MEDIUM (8yr), 3 = HIGH (2yr)", example = "1") + private Integer riskCategory; + + @Schema(description = "PAN number in format AAAAA0000A – mandatory per RBI KYC norms", example = "ABCDE1234F", + requiredMode = Schema.RequiredMode.REQUIRED) + private String panNumber; + + @Schema(description = "Aadhaar number – last 4 digits visible, first 8 masked (e.g. XXXXXXXX1234). " + + "Full Aadhaar must NOT be stored per UIDAI directive.", example = "XXXXXXXX1234") + private String aadhaarNumberMasked; + + @Schema(description = "15-character GSTIN – mandatory for NON_INDIVIDUAL clients", example = "27ABCDE1234F1Z5") + private String gstinNumber; + + @Schema(description = "Optional remarks / notes", example = "Documents submitted at branch") + private String remarks; + + // ── Verify action only ──────────────────────────────────────────────────── + + @Schema(description = "Central KYC Records Registry (CKYCRR) number issued by CERSAI (14 characters)", example = "12345678901234") + private String ckycNumber; + + @Schema(description = "Trigger PAN verification via LincPay API", example = "true") + private Boolean verifyPan; + + @Schema(description = "Trigger Aadhaar OTP verification via LincPay API. " + + "Requires aadhaarRefNumber (from aadhaar-generate-otp) and aadhaarOtp.", example = "true") + private Boolean verifyAadhaar; + + @Schema(description = "Trigger GSTIN verification via LincPay API", example = "false") + private Boolean verifyGstin; + + // ── Aadhaar OTP flow (two-step) ─────────────────────────────────────────── + + @Schema(description = "Full 12-digit Aadhaar number. Used ONLY for the aadhaar-generate-otp command. " + + "Never stored by the system per UIDAI directive.", example = "123456789012") + private String aadhaarNumber; + + @Schema(description = "Reference number returned by the aadhaar-generate-otp command " + + "(LincPay refNumber). Supply this along with aadhaarOtp in the verify command " + + "when verifyAadhaar=true.", example = "LPY-20260226-ABCDE") + private String aadhaarRefNumber; + + @Schema(description = "OTP received by the client on their Aadhaar-registered mobile. " + + "Required in the verify command when verifyAadhaar=true.", example = "123456") + private String aadhaarOtp; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java index 5517b42a5..bd304d6c1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientFamilyMembers.java @@ -24,6 +24,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.math.BigDecimal; import java.time.LocalDate; import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; @@ -76,9 +77,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { @Column(name = "date_of_birth", nullable = true) private LocalDate dateOfBirth; + @Column(name = "nomination_type") + private String nominationType; + + @Column(name = "percentage_share") + private BigDecimal percentageShare; + + @Column(name = "remarks") + private String remarks; + private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName, final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship, - final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession) { + final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession, + final String nominationType, final BigDecimal percentageShare, final String remarks) { this.client = client; this.firstName = firstName; @@ -93,6 +104,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { this.gender = gender; this.dateOfBirth = dateOfBirth; this.profession = profession; + this.nominationType = nominationType; + this.percentageShare = percentageShare; + this.remarks = remarks; } public ClientFamilyMembers() { @@ -101,9 +115,10 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { public static ClientFamilyMembers fromJson(final Client client, final String firstName, final String middleName, final String lastName, final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship, - final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession) { + final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession, + final String nominationType, final BigDecimal percentageShare, final String remarks) { return new ClientFamilyMembers(client, firstName, middleName, lastName, qualification, mobileNumber, age, isDependent, relationship, - maritalStatus, gender, dateOfBirth, profession); + maritalStatus, gender, dateOfBirth, profession, nominationType, percentageShare, remarks); } public Client getClient() { @@ -210,4 +225,28 @@ public class ClientFamilyMembers extends AbstractPersistableCustom { this.isDependent = isDependent; } + public String getNominationType() { + return this.nominationType; + } + + public void setNominationType(String nominationType) { + this.nominationType = nominationType; + } + + public BigDecimal getPercentageShare() { + return this.percentageShare; + } + + public void setPercentageShare(BigDecimal percentageShare) { + this.percentageShare = percentageShare; + } + + public String getRemarks() { + return this.remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/GenerateAadhaarOtpClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/GenerateAadhaarOtpClientKycCommandHandler.java new file mode 100644 index 000000000..972c324ed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/GenerateAadhaarOtpClientKycCommandHandler.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Handles the {@code aadhaar-generate-otp} KYC command. + * + *

Calls LincPay {@code /aadhaar/generate-otp} with the client's full Aadhaar number + * (supplied in the request body as {@code aadhaarNumber}). The full Aadhaar is never + * stored. The returned {@link CommandProcessingResult} carries a {@code "aadhaarRefNumber"} + * change entry that the calling application must pass back to the client so they can supply + * it together with their OTP in the subsequent verify command. + */ +@Service +@CommandType(entity = "CLIENTKYC", action = "GENERATEAADHAROTP") +@RequiredArgsConstructor +public class GenerateAadhaarOtpClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.generateAadhaarOtp( + command.getClientId(), command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/InitiateClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/InitiateClientKycCommandHandler.java new file mode 100644 index 000000000..acb7ba7e3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/InitiateClientKycCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "CLIENTKYC", action = "CREATE") +@RequiredArgsConstructor +public class InitiateClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.initiateKyc(command.getClientId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/RejectClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/RejectClientKycCommandHandler.java new file mode 100644 index 000000000..336c1080a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/RejectClientKycCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "CLIENTKYC", action = "REJECT") +@RequiredArgsConstructor +public class RejectClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.rejectKyc(command.getClientId(), command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/UpdateClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/UpdateClientKycCommandHandler.java new file mode 100644 index 000000000..0fb074550 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/UpdateClientKycCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "CLIENTKYC", action = "UPDATE") +@RequiredArgsConstructor +public class UpdateClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.updateKyc(command.getClientId(), command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyAadhaarClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyAadhaarClientKycCommandHandler.java new file mode 100644 index 000000000..d8642b881 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyAadhaarClientKycCommandHandler.java @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Handles the {@code verify-aadhaar} KYC command (Step 2 of Aadhaar e-KYC). + * + *

Calls LincPay {@code /aadhaar/verify-otp} with the {@code aadhaarRefNumber} returned + * by the {@code aadhaar-generate-otp} step and the {@code aadhaarOtp} entered by the client. + * On success, marks {@code aadhaarVerified = true} on the KYC record. + * The full Aadhaar number is never stored per UIDAI directive. + */ +@Service +@CommandType(entity = "CLIENTKYC", action = "VERIFYAADHAAR") +@RequiredArgsConstructor +public class VerifyAadhaarClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.verifyAadhaar( + command.getClientId(), command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyClientKycCommandHandler.java new file mode 100644 index 000000000..d78e054c1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyClientKycCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "CLIENTKYC", action = "VERIFY") +@RequiredArgsConstructor +public class VerifyClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.verifyKyc(command.getClientId(), command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyGstinClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyGstinClientKycCommandHandler.java new file mode 100644 index 000000000..688e9fcfa --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyGstinClientKycCommandHandler.java @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Handles the {@code verify-gstin} KYC command. + * + *

Calls LincPay {@code /validate-gstn} with the GSTIN stored on the KYC record. + * On success, marks {@code gstinVerified = true} on the KYC record without changing + * the overall KYC status. + */ +@Service +@CommandType(entity = "CLIENTKYC", action = "VERIFYGSTIN") +@RequiredArgsConstructor +public class VerifyGstinClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.verifyGstin( + command.getClientId(), command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyPanClientKycCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyPanClientKycCommandHandler.java new file mode 100644 index 000000000..1ca5fc00b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/handler/VerifyPanClientKycCommandHandler.java @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.service.ClientKycWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Handles the {@code verify-pan} KYC command. + * + *

Calls LincPay {@code /validate-pan} with the PAN stored on the KYC record. + * On success, marks {@code panVerified = true} on the KYC record without changing + * the overall KYC status (allowing individual document verifications to proceed + * independently before a final status transition). + */ +@Service +@CommandType(entity = "CLIENTKYC", action = "VERIFYPAN") +@RequiredArgsConstructor +public class VerifyPanClientKycCommandHandler implements NewCommandSourceHandler { + + private final ClientKycWritePlatformService clientKycWritePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return clientKycWritePlatformService.verifyPan( + command.getClientId(), command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java new file mode 100644 index 000000000..ebb80d612 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.kyc; + +import lombok.Builder; +import lombok.Getter; + +/** + * Captures the result of a 3rd-party KYC document verification call. + * The actual API response payload is kept as a raw string so it can be + * stored verbatim in the audit log ({@code m_client_kyc_verification_log}). + */ +@Getter +@Builder +public class KycVerificationResult { + + public enum Status { + SUCCESS, FAILURE, PENDING, ERROR + } + + private final Status status; + private final String referenceId; + private final String rawResponse; + private final String errorMessage; + + /** Convenience factory for a successful verification. */ + public static KycVerificationResult success(final String referenceId, final String rawResponse) { + return KycVerificationResult.builder() + .status(Status.SUCCESS) + .referenceId(referenceId) + .rawResponse(rawResponse) + .build(); + } + + /** Convenience factory for a failed verification. */ + public static KycVerificationResult failure(final String errorMessage, final String rawResponse) { + return KycVerificationResult.builder() + .status(Status.FAILURE) + .errorMessage(errorMessage) + .rawResponse(rawResponse) + .build(); + } + + public boolean isSuccess() { + return Status.SUCCESS == this.status; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationService.java new file mode 100644 index 000000000..36fe74a2c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationService.java @@ -0,0 +1,77 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.kyc; + +/** + * Pluggable interface for 3rd-party KYC document verification. + * + *

The active implementation is {@code LincPayKycVerificationServiceImpl} which calls + * the LincPay eKYC API ({@code https://ekyc.lincpay.in/EKYC/ekyc/}). + * Credentials are configured via {@code fineract.kyc.lincpay.*} in + * {@code application.properties}. + * + *

Aadhaar verification is a two-step OTP flow: + *

    + *
  1. Call {@link #generateAadhaarOtp(String)} with the client's full Aadhaar – returns a + * {@code refNumber} that must be passed back to the client-facing app.
  2. + *
  3. Call {@link #verifyAadhaar(String, String)} with the {@code refNumber} and the OTP + * that the client received on their registered mobile.
  4. + *
+ */ +public interface KycVerificationService { + + /** + * Step 1 of Aadhaar e-KYC: generate an OTP to the client's UIDAI-registered mobile. + * + *

IMPORTANT: The full Aadhaar number must NOT be stored after this call + * per UIDAI directive. Only the masked Aadhaar (last 4 digits) may be persisted. + * + * @param aadhaarNumber Full 12-digit Aadhaar number + * @return result whose {@link KycVerificationResult#getReferenceId()} contains the + * {@code refNumber} required for the subsequent OTP verification call + */ + KycVerificationResult generateAadhaarOtp(String aadhaarNumber); + + /** + * Step 2 of Aadhaar e-KYC: verify the OTP entered by the client. + * + * @param refNumber Reference number returned by {@link #generateAadhaarOtp(String)} + * @param otp One-time password received by the client on their registered mobile + * @return verification result + */ + KycVerificationResult verifyAadhaar(String refNumber, String otp); + + /** + * Verify a PAN number against the Income Tax Department database via the configured 3rd-party API. + * + * @param panNumber 10-character PAN (e.g. {@code ABCDE1234F}) + * @param clientName Name of the client – used for name-match verification + * @param dateOfBirth DOB in {@code yyyy-MM-dd} format (may be null for non-individual) + * @return verification result + */ + KycVerificationResult verifyPan(String panNumber, String clientName, String dateOfBirth); + + /** + * Verify a GSTIN number via the GST Common Portal API. + * + * @param gstinNumber 15-character GSTIN + * @return verification result + */ + KycVerificationResult verifyGstin(String gstinNumber); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationServiceImpl.java new file mode 100644 index 000000000..439532058 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationServiceImpl.java @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.kyc; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Fallback (stub) implementation of {@link KycVerificationService}. + * + *

NOT active in production. The primary implementation is + * {@link LincPayKycVerificationServiceImpl}, which is marked {@code @Primary}. + * This stub is retained as a compile-time safety net and for local/unit-test use + * when LincPay credentials are unavailable. + * + *

To switch back to the stub (e.g. in tests), remove the {@code @Primary} annotation + * from {@link LincPayKycVerificationServiceImpl} and add {@code @Primary} here. + */ +// NOTE: intentionally NOT annotated with @Service to avoid ambiguous bean conflict. +// Activate it by adding @Service @Primary if the LincPay implementation is disabled. +public class KycVerificationServiceImpl implements KycVerificationService { + + private static final Logger LOG = LoggerFactory.getLogger(KycVerificationServiceImpl.class); + + @Override + public KycVerificationResult generateAadhaarOtp(final String aadhaarNumber) { + LOG.info("[KYC-STUB] Aadhaar generate-OTP called. Replace with real LincPay implementation."); + // Stub: return a fake refNumber – real code calls LincPayKycVerificationServiceImpl + return KycVerificationResult.success("STUB-OTP-REF-" + System.currentTimeMillis(), + "{\"status\":\"SUCCESS\",\"message\":\"Stub: OTP sent\",\"refNumber\":\"STUB-REF\"}"); + } + + @Override + public KycVerificationResult verifyAadhaar(final String refNumber, final String otp) { + LOG.info("[KYC-STUB] Aadhaar verify-OTP called for refNumber: {}. Replace with LincPay.", refNumber); + return KycVerificationResult.success(refNumber, + "{\"status\":\"SUCCESS\",\"message\":\"Stub: Aadhaar OTP verified\"}"); + } + + @Override + public KycVerificationResult verifyPan(final String panNumber, final String clientName, + final String dateOfBirth) { + LOG.info("[KYC-STUB] PAN verification called for PAN: {}. Replace with LincPay.", panNumber); + return KycVerificationResult.success("STUB-PAN-REF-" + panNumber, + "{\"status\":\"SUCCESS\",\"message\":\"Stub: PAN verified\",\"pan\":\"" + panNumber + "\"}"); + } + + @Override + public KycVerificationResult verifyGstin(final String gstinNumber) { + LOG.info("[KYC-STUB] GSTIN verification called for GSTIN: {}. Replace with LincPay.", gstinNumber); + return KycVerificationResult.success("STUB-GSTIN-REF-" + gstinNumber, + "{\"status\":\"SUCCESS\",\"message\":\"Stub: GSTIN verified\",\"gstin\":\"" + gstinNumber + "\"}"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java new file mode 100644 index 000000000..0252bd3fe --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java @@ -0,0 +1,336 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.kyc; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestTemplate; + +/** + * Real implementation of {@link KycVerificationService} that calls the + * LincPay eKYC API. + * + *

Configure the following properties in {@code application.properties}: + *

+ *   fineract.kyc.lincpay.api-key=YOUR_API_KEY
+ *   fineract.kyc.lincpay.secret=YOUR_SECRET
+ *   fineract.kyc.lincpay.base-url=https://ekyc.lincpay.in/EKYC/ekyc
+ *   fineract.kyc.lincpay.path.aadhaar-generate-otp=/aadhaar/generate-otp
+ *   fineract.kyc.lincpay.path.aadhaar-verify-otp=/aadhaar/verify-otp
+ *   fineract.kyc.lincpay.path.pan-validate=/validate-pan
+ *   fineract.kyc.lincpay.path.gstin-validate=/validate-gstn
+ * 
+ * + *

Aadhaar OTP flow (two-step): + *

    + *
  1. {@link #generateAadhaarOtp(String)} – takes full Aadhaar, triggers OTP to + * client's registered mobile, returns a {@code refNumber}.
  2. + *
  3. {@link #verifyAadhaar(String, String)} – takes the {@code refNumber} and the OTP + * entered by the client; returns success/failure.
  4. + *
+ * + *

PAN: Single-step – {@link #verifyPan(String, String, String)} validates PAN, + * name, and optional DOB against the Income Tax Department records. + * + *

GSTIN: Single-step – {@link #verifyGstin(String)} validates the GST + * registration number via the GST Common Portal. + */ +@Primary +@Service +public class LincPayKycVerificationServiceImpl implements KycVerificationService { + + private static final Logger LOG = LoggerFactory.getLogger(LincPayKycVerificationServiceImpl.class); + + @Value("${fineract.kyc.lincpay.api-key}") + private String apiKey; + + @Value("${fineract.kyc.lincpay.secret}") + private String secret; + + @Value("${fineract.kyc.lincpay.base-url}") + private String baseUrl; + + @Value("${fineract.kyc.lincpay.path.aadhaar-generate-otp}") + private String aadhaarGenerateOtpPath; + + @Value("${fineract.kyc.lincpay.path.aadhaar-verify-otp}") + private String aadhaarVerifyOtpPath; + + @Value("${fineract.kyc.lincpay.path.pan-validate}") + private String panValidatePath; + + @Value("${fineract.kyc.lincpay.path.gstin-validate}") + private String gstinValidatePath; + + private final RestTemplate restTemplate; + + public LincPayKycVerificationServiceImpl() { + this.restTemplate = new RestTemplate(); + } + + // ── Aadhaar OTP Generate ────────────────────────────────────────────────── + + /** + * POST https://ekyc.lincpay.in/EKYC/ekyc/aadhaar/generate-otp + * Body: { "aadhaarNumber": "...", "apikey": "...", "secrete": "..." } + * Returns a refNumber in the result's referenceId for use in the verify-otp call. + */ + @Override + public KycVerificationResult generateAadhaarOtp(final String aadhaarNumber) { + final String url = baseUrl + aadhaarGenerateOtpPath; + try { + final JsonObject requestBody = new JsonObject(); + requestBody.addProperty("aadhaarNumber", aadhaarNumber); + requestBody.addProperty("apikey", apiKey); + requestBody.addProperty("secrete", secret); + + final String rawResponse = post(url, requestBody.toString()); + final JsonObject response = parseJson(rawResponse); + + if (isSuccess(response)) { + // LincPay nests reference_id inside "data" object + String refNumber = null; + if (response.has("data") && response.get("data").isJsonObject()) { + final JsonObject data = response.getAsJsonObject("data"); + refNumber = extractField(data, "reference_id", "refNumber", "ref_number", + "transactionId", "txnId", "requestId"); + } + // Fallback: check top-level fields + if (refNumber == null) { + refNumber = extractField(response, "reference_id", "refNumber", "ref_number", + "transaction_id", "transactionId", "txnId", "requestId"); + } + if (refNumber == null) { + LOG.warn("LincPay Aadhaar generate-otp: success status but no refNumber in response: {}", + rawResponse); + return KycVerificationResult.failure("OTP sent but refNumber missing in response", + rawResponse); + } + LOG.info("LincPay Aadhaar OTP generated successfully. refNumber: {}", refNumber); + return KycVerificationResult.success(refNumber, rawResponse); + } + + final String errorMsg = extractMessage(response); + LOG.warn("LincPay Aadhaar generate-otp failed: {}", errorMsg); + return KycVerificationResult.failure(errorMsg, rawResponse); + + } catch (final HttpClientErrorException | HttpServerErrorException e) { + LOG.error("LincPay Aadhaar generate-otp HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString()); + return KycVerificationResult.failure( + "OTP generation failed (HTTP " + e.getStatusCode() + "): " + e.getResponseBodyAsString(), null); + } catch (final Exception e) { + LOG.error("LincPay Aadhaar generate-otp unexpected error", e); + return KycVerificationResult.failure("OTP generation failed: " + e.getMessage(), null); + } + } + + // ── Aadhaar OTP Verify ──────────────────────────────────────────────────── + + /** + * POST https://ekyc.lincpay.in/EKYC/ekyc/aadhaar/verify-otp + * Body: { "apikey": "...", "otp": "...", "refNumber": "...", "secrete": "..." } + */ + @Override + public KycVerificationResult verifyAadhaar(final String refNumber, final String otp) { + final String url = baseUrl + aadhaarVerifyOtpPath; + try { + final JsonObject requestBody = new JsonObject(); + requestBody.addProperty("apikey", apiKey); + requestBody.addProperty("otp", otp); + requestBody.addProperty("refNumber", refNumber); + requestBody.addProperty("secrete", secret); + + final String rawResponse = post(url, requestBody.toString()); + final JsonObject response = parseJson(rawResponse); + + if (isSuccess(response)) { + LOG.info("LincPay Aadhaar OTP verified successfully for refNumber: {}", refNumber); + return KycVerificationResult.success(refNumber, rawResponse); + } + + final String errorMsg = extractMessage(response); + LOG.warn("LincPay Aadhaar verify-otp failed for refNumber {}: {}", refNumber, errorMsg); + return KycVerificationResult.failure(errorMsg, rawResponse); + + } catch (final HttpClientErrorException | HttpServerErrorException e) { + LOG.error("LincPay Aadhaar verify-otp HTTP error: {} {}", e.getStatusCode(), e.getResponseBodyAsString()); + return KycVerificationResult.failure( + "Aadhaar OTP verification failed (HTTP " + e.getStatusCode() + "): " + + e.getResponseBodyAsString(), null); + } catch (final Exception e) { + LOG.error("LincPay Aadhaar verify-otp unexpected error", e); + return KycVerificationResult.failure("Aadhaar OTP verification failed: " + e.getMessage(), null); + } + } + + // ── PAN Validation ──────────────────────────────────────────────────────── + + /** + * POST https://ekyc.lincpay.in/EKYC/ekyc/validate-pan + * Body: { "apikey": "...", "dob": "...", "name": "...", "panNumber": "...", "secrete": "..." } + */ + @Override + public KycVerificationResult verifyPan(final String panNumber, final String clientName, + final String dateOfBirth) { + final String url = baseUrl + panValidatePath; + try { + final JsonObject requestBody = new JsonObject(); + requestBody.addProperty("apikey", apiKey); + requestBody.addProperty("panNumber", panNumber); + requestBody.addProperty("name", clientName != null ? clientName : ""); + requestBody.addProperty("dob", dateOfBirth != null ? dateOfBirth : ""); + requestBody.addProperty("secrete", secret); + + LOG.info("LincPay PAN request URL: {}, body: {}", url, requestBody); + final String rawResponse = post(url, requestBody.toString()); + LOG.info("LincPay PAN raw response: {}", rawResponse); + final JsonObject response = parseJson(rawResponse); + + if (isSuccess(response)) { + LOG.info("LincPay PAN verified successfully for PAN: {}", panNumber); + return KycVerificationResult.success("PAN-" + panNumber, rawResponse); + } + + final String errorMsg = extractMessage(response); + LOG.warn("LincPay PAN validation failed for PAN {}: {}", panNumber, errorMsg); + return KycVerificationResult.failure(errorMsg, rawResponse); + + } catch (final HttpClientErrorException | HttpServerErrorException e) { + LOG.error("LincPay PAN validation HTTP error for PAN {}: {} {} body: {}", panNumber, + e.getStatusCode(), e.getResponseBodyAsString(), e.getResponseBodyAsString()); + return KycVerificationResult.failure( + "PAN verification failed (HTTP " + e.getStatusCode() + "): " + + e.getResponseBodyAsString(), null); + } catch (final Exception e) { + LOG.error("LincPay PAN validation unexpected error for PAN: {}", panNumber, e); + return KycVerificationResult.failure("PAN verification failed: " + e.getMessage(), null); + } + } + + // ── GSTIN Validation ────────────────────────────────────────────────────── + + /** + * POST https://ekyc.lincpay.in/EKYC/ekyc/validate-gstn + * Body: { "apikey": "...", "gstNumber": "...", "secrete": "..." } + */ + @Override + public KycVerificationResult verifyGstin(final String gstinNumber) { + final String url = baseUrl + gstinValidatePath; + try { + final JsonObject requestBody = new JsonObject(); + requestBody.addProperty("apikey", apiKey); + requestBody.addProperty("gstNumber", gstinNumber); + requestBody.addProperty("secrete", secret); + + final String rawResponse = post(url, requestBody.toString()); + final JsonObject response = parseJson(rawResponse); + + if (isSuccess(response)) { + LOG.info("LincPay GSTIN verified successfully for GSTIN: {}", gstinNumber); + return KycVerificationResult.success("GSTN-" + gstinNumber, rawResponse); + } + + final String errorMsg = extractMessage(response); + LOG.warn("LincPay GSTIN validation failed for GSTIN {}: {}", gstinNumber, errorMsg); + return KycVerificationResult.failure(errorMsg, rawResponse); + + } catch (final HttpClientErrorException | HttpServerErrorException e) { + LOG.error("LincPay GSTIN validation HTTP error for GSTIN {}: {} {}", gstinNumber, + e.getStatusCode(), e.getResponseBodyAsString()); + return KycVerificationResult.failure( + "GSTIN verification failed (HTTP " + e.getStatusCode() + "): " + + e.getResponseBodyAsString(), null); + } catch (final Exception e) { + LOG.error("LincPay GSTIN validation unexpected error for GSTIN: {}", gstinNumber, e); + return KycVerificationResult.failure("GSTIN verification failed: " + e.getMessage(), null); + } + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + private String post(final String url, final String requestBodyJson) { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("accept", "*/*"); + + final HttpEntity entity = new HttpEntity<>(requestBodyJson, headers); + final ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + return response.getBody(); + } + + private JsonObject parseJson(final String raw) { + if (raw == null || raw.isBlank()) { + return new JsonObject(); + } + final JsonElement parsed = JsonParser.parseString(raw); + return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject(); + } + + /** + * LincPay may return status in several forms. Check common field names. + */ + private boolean isSuccess(final JsonObject response) { + for (final String key : new String[] { "status", "statusCode", "responseCode", "code" }) { + if (response.has(key) && !response.get(key).isJsonNull()) { + final String val = response.get(key).getAsString().trim(); + if ("SUCCESS".equalsIgnoreCase(val) || "200".equals(val) || "1".equals(val) + || "true".equalsIgnoreCase(val)) { + return true; + } + // explicit failure values + if ("FAILURE".equalsIgnoreCase(val) || "FAILED".equalsIgnoreCase(val) + || "ERROR".equalsIgnoreCase(val)) { + return false; + } + } + } + if (response.has("success") && !response.get("success").isJsonNull()) { + return response.get("success").getAsBoolean(); + } + return false; + } + + /** Try common field names to extract a string value from the response. */ + private String extractField(final JsonObject response, final String... fieldNames) { + for (final String field : fieldNames) { + if (response.has(field) && !response.get(field).isJsonNull()) { + return response.get(field).getAsString(); + } + } + return null; + } + + private String extractMessage(final JsonObject response) { + final String msg = extractField(response, "message", "msg", "error", "description", + "errorMessage", "errorDesc", "remarks"); + return msg != null ? msg : "Verification failed – no error message returned by vendor"; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java index af5c6c473..42b10247e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientFamilyMemberCommandFromApiJsonDeserializer.java @@ -59,9 +59,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer { public static final String LOCALE = "locale"; public static final String DATE_FORMAT = "dateFormat"; public static final String FAMILY_MEMBERS = "familyMembers"; + public static final String NOMINATION_TYPE = "nominationType"; + public static final String PERCENTAGE_SHARE = "percentageShare"; + public static final String REMARKS = "remarks"; private static final Set SUPPORTED_PARAMETERS = new HashSet<>( Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT, - RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS)); + RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS, + NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS)); public static final String FAMILY_MEMBERS1 = "FamilyMembers"; public static final String RELATION_SHIP_ID = "relationShipId"; private final FromJsonHelper fromApiJsonHelper; @@ -188,6 +192,21 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer { } + if (this.fromApiJsonHelper.extractStringNamed(NOMINATION_TYPE, element) != null) { + final String nominationType = this.fromApiJsonHelper.extractStringNamed(NOMINATION_TYPE, element); + baseDataValidator.reset().parameter(NOMINATION_TYPE).value(nominationType).notBlank().notExceedingLengthOf(50); + } + + if (this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(PERCENTAGE_SHARE, element) != null) { + final java.math.BigDecimal percentageShare = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(PERCENTAGE_SHARE, element); + baseDataValidator.reset().parameter(PERCENTAGE_SHARE).value(percentageShare).notNull().zeroOrPositiveAmount(); + } + + if (this.fromApiJsonHelper.extractStringNamed(REMARKS, element) != null) { + final String remarks = this.fromApiJsonHelper.extractStringNamed(REMARKS, element); + baseDataValidator.reset().parameter(REMARKS).value(remarks).notExceedingLengthOf(500); + } + } public void validateForUpdate(final long familyMemberId, String json) { @@ -258,6 +277,21 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer { } + if (this.fromApiJsonHelper.extractStringNamed(NOMINATION_TYPE, element) != null) { + final String nominationType = this.fromApiJsonHelper.extractStringNamed(NOMINATION_TYPE, element); + baseDataValidator.reset().parameter(NOMINATION_TYPE).value(nominationType).notBlank().notExceedingLengthOf(50); + } + + if (this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(PERCENTAGE_SHARE, element) != null) { + final java.math.BigDecimal percentageShare = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(PERCENTAGE_SHARE, element); + baseDataValidator.reset().parameter(PERCENTAGE_SHARE).value(percentageShare).notNull().zeroOrPositiveAmount(); + } + + if (this.fromApiJsonHelper.extractStringNamed(REMARKS, element) != null) { + final String remarks = this.fromApiJsonHelper.extractStringNamed(REMARKS, element); + baseDataValidator.reset().parameter(REMARKS).value(remarks).notExceedingLengthOf(500); + } + } public void validateForDelete(final long familyMemberId) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..d56b27270 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/serialization/ClientKycCommandFromApiJsonDeserializer.java @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.serialization; + +import com.google.gson.JsonElement; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.Set; +import com.google.gson.reflect.TypeToken; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.portfolio.client.command.ClientKycCommand; +import org.apache.fineract.portfolio.client.data.ClientKycApiConstants; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ClientKycCommandFromApiJsonDeserializer { + + private final FromJsonHelper fromApiJsonHelper; + + public ClientKycCommand commandFromApiJson(final String json, final Set supportedParams) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supportedParams); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + + final Integer kycType = this.fromApiJsonHelper.extractIntegerWithLocaleNamed( + ClientKycApiConstants.kycTypeParam, element); + final Integer riskCategory = this.fromApiJsonHelper.extractIntegerWithLocaleNamed( + ClientKycApiConstants.riskCategoryParam, element); + final String panNumber = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.panNumberParam, element); + final String aadhaarNumberMasked = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.aadhaarNumberMaskedParam, element); + final String gstinNumber = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.gstinNumberParam, element); + final String remarks = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.remarksParam, element); + final String ckycNumber = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.ckycNumberParam, element); + final Boolean verifyPan = this.fromApiJsonHelper.extractBooleanNamed( + ClientKycApiConstants.verifyPanParam, element); + final Boolean verifyAadhaar = this.fromApiJsonHelper.extractBooleanNamed( + ClientKycApiConstants.verifyAadhaarParam, element); + final Boolean verifyGstin = this.fromApiJsonHelper.extractBooleanNamed( + ClientKycApiConstants.verifyGstinParam, element); + + // Aadhaar OTP flow – full Aadhaar (generate-otp) and refNumber+OTP (verify-otp) + final String aadhaarNumber = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.aadhaarNumberParam, element); + final String aadhaarRefNumber = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.aadhaarRefNumberParam, element); + final String aadhaarOtp = this.fromApiJsonHelper.extractStringNamed( + ClientKycApiConstants.aadhaarOtpParam, element); + + return new ClientKycCommand(kycType, riskCategory, panNumber, aadhaarNumberMasked, + gstinNumber, remarks, ckycNumber, verifyPan, verifyAadhaar, verifyGstin, + aadhaarNumber, aadhaarRefNumber, aadhaarOtp); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java index bc4eab39e..8af766fe9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersReadPlatformServiceImpl.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.client.service; +import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; @@ -55,7 +56,8 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM return "fmb.id AS id, fmb.client_id AS clientId, fmb.firstname AS firstName, fmb.middlename AS middleName," + "fmb.lastname AS lastName,fmb.qualification AS qualification,fmb.mobile_number as mobileNumber,fmb.age as age,fmb.is_dependent as isDependent,cv.code_value AS relationship,fmb.relationship_cv_id AS relationshipId," + "c.code_value AS maritalStatus,fmb.marital_status_cv_id AS maritalStatusId," - + "c1.code_value AS gender, fmb.gender_cv_id AS genderId, fmb.date_of_birth AS dateOfBirth, c2.code_value AS profession, fmb.profession_cv_id AS professionId" + + "c1.code_value AS gender, fmb.gender_cv_id AS genderId, fmb.date_of_birth AS dateOfBirth, c2.code_value AS profession, fmb.profession_cv_id AS professionId," + + "fmb.nomination_type AS nominationType, fmb.percentage_share AS percentageShare, fmb.remarks AS remarks" + " FROM m_family_members fmb" + " LEFT JOIN m_code_value cv ON fmb.relationship_cv_id=cv.id" + " LEFT JOIN m_code_value c ON fmb.marital_status_cv_id=c.id" + " LEFT JOIN m_code_value c1 ON fmb.gender_cv_id=c1.id" + " LEFT JOIN m_code_value c2 ON fmb.profession_cv_id=c2.id"; @@ -81,11 +83,15 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM final LocalDate dateOfBirth = JdbcSupport.getLocalDate(rs, "dateOfBirth"); final String profession = rs.getString("profession"); final long professionId = rs.getLong("professionId"); + final String nominationType = rs.getString("nominationType"); + final BigDecimal percentageShare = rs.getBigDecimal("percentageShare"); + final String remarks = rs.getString("remarks"); return ClientFamilyMembersData.builder().id(id).clientId(clientId).firstName(firstName).middleName(middleName) .lastName(lastName).qualification(qualification).mobileNumber(mobileNumber).age(age).isDependent(isDependent) .relationship(relationship).relationshipId(relationshipId).maritalStatus(maritalStatus).maritalStatusId(maritalStatusId) - .gender(gender).genderId(genderId).dateOfBirth(dateOfBirth).profession(profession).professionId(professionId).build(); + .gender(gender).genderId(genderId).dateOfBirth(dateOfBirth).profession(profession).professionId(professionId) + .nominationType(nominationType).percentageShare(percentageShare).remarks(remarks).build(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java index 78d44c703..98868990e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientFamilyMembersWritePlatformServiceImpl.java @@ -22,6 +22,7 @@ package org.apache.fineract.portfolio.client.service; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; @@ -83,6 +84,9 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily Long age = null; Boolean isDependent = false; LocalDate dateOfBirth = null; + String nominationType = null; + BigDecimal percentageShare = null; + String remarks = null; this.context.authenticatedUser(); apiJsonDeserializer.validateForCreate(clientId, command.json()); @@ -95,6 +99,9 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily mobileNumber = command.stringValueOfParameterNamed("mobileNumber"); age = command.longValueOfParameterNamed("age"); isDependent = command.booleanObjectValueOfParameterNamed("isDependent"); + nominationType = command.stringValueOfParameterNamed("nominationType"); + percentageShare = command.bigDecimalValueOfParameterNamed("percentageShare"); + remarks = command.stringValueOfParameterNamed("remarks"); if (command.longValueOfParameterNamed("relationshipId") != null) { relationshipId = command.longValueOfParameterNamed("relationshipId"); @@ -119,7 +126,8 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily dateOfBirth = command.localDateValueOfParameterNamed("dateOfBirth"); ClientFamilyMembers clientFamilyMembers = ClientFamilyMembers.fromJson(client, firstName, middleName, lastName, qualification, - mobileNumber, age, isDependent, relationship, maritalStatus, gender, dateOfBirth, profession); + mobileNumber, age, isDependent, relationship, maritalStatus, gender, dateOfBirth, profession, + nominationType, percentageShare, remarks); this.clientFamilyRepository.saveAndFlush(clientFamilyMembers); @@ -146,6 +154,9 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily String mobileNumber = ""; Long age = null; Boolean isDependent = false; + String nominationType = null; + BigDecimal percentageShare = null; + String remarks = null; this.context.authenticatedUser(); @@ -231,8 +242,21 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily } + if (member.get("nominationType") != null) { + nominationType = member.get("nominationType").getAsString(); + } + + if (member.get("percentageShare") != null) { + percentageShare = member.get("percentageShare").getAsBigDecimal(); + } + + if (member.get("remarks") != null) { + remarks = member.get("remarks").getAsString(); + } + familyMember = ClientFamilyMembers.fromJson(client, firstName, middleName, lastName, qualification, mobileNumber, age, - isDependent, relationship, maritalStatus, gender, dateOfBirth, profession); + isDependent, relationship, maritalStatus, gender, dateOfBirth, profession, + nominationType, percentageShare, remarks); this.clientFamilyRepository.saveAndFlush(familyMember); @@ -342,6 +366,18 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily } + if (command.stringValueOfParameterNamed("nominationType") != null) { + clientFamilyMember.setNominationType(command.stringValueOfParameterNamed("nominationType")); + } + + if (command.bigDecimalValueOfParameterNamed("percentageShare") != null) { + clientFamilyMember.setPercentageShare(command.bigDecimalValueOfParameterNamed("percentageShare")); + } + + if (command.stringValueOfParameterNamed("remarks") != null) { + clientFamilyMember.setRemarks(command.stringValueOfParameterNamed("remarks")); + } + // ClientFamilyMembers // clientFamilyMembers=ClientFamilyMembers.fromJson(client, firstName, // middleName, lastName, qualification, relationship, maritalStatus, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java new file mode 100644 index 000000000..b84937288 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformService.java @@ -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.service; + +import org.apache.fineract.portfolio.client.data.ClientKycData; + +public interface ClientKycReadPlatformService { + + /** + * Retrieve the KYC record for a specific KYC id. + */ + ClientKycData retrieveClientKyc(Long kycId); + + /** + * Retrieve the KYC record for a client. + */ + ClientKycData retrieveClientKycByClientId(Long clientId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java new file mode 100644 index 000000000..e62a7a445 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -0,0 +1,86 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.client.data.ClientKycData; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.ClientKycStatus; +import org.apache.fineract.portfolio.client.domain.ClientKycType; +import org.apache.fineract.portfolio.client.domain.KycRiskCategory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformService { + + private final ClientKycRepositoryWrapper kycRepositoryWrapper; + + @Override + public ClientKycData retrieveClientKyc(final Long kycId) { + final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); + return toData(kyc); + } + + @Override + public ClientKycData retrieveClientKycByClientId(final Long clientId) { + final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId); + return toData(kyc); + } + + private ClientKycData toData(final ClientKyc kyc) { + final ClientKycStatus status = ClientKycStatus.fromInt(kyc.getKycStatusValue()); + final ClientKycType type = ClientKycType.fromInt(kyc.getKycTypeValue()); + final KycRiskCategory risk = KycRiskCategory.fromInt(kyc.getRiskCategoryValue()); + + return ClientKycData.builder() + .id(kyc.getId()) + .clientId(kyc.getClient().getId()) + .clientDisplayName(kyc.getClient().getDisplayName()) + .kycStatus(status.getValue()) + .kycStatusCode(status.getCode()) + .kycType(type.getValue()) + .kycTypeCode(type.getCode()) + .riskCategory(risk.getValue()) + .riskCategoryCode(risk.getCode()) + .panNumber(kyc.getPanNumber()) + .panVerified(kyc.isPanVerified()) + .panVerifiedOn(kyc.getPanVerifiedOn()) + .panFullName(kyc.getPanFullName()) + .panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus()) + .panCategory(kyc.getPanCategory()) + .panStatus(kyc.getPanStatus()) + .aadhaarNumberMasked(kyc.getAadhaarNumberMasked()) + .aadhaarVerified(kyc.isAadhaarVerified()) + .aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn()) + .gstinNumber(kyc.getGstinNumber()) + .gstinVerified(kyc.isGstinVerified()) + .gstinVerifiedOn(kyc.getGstinVerifiedOn()) + .ckycNumber(kyc.getCkycNumber()) + .initiatedOn(kyc.getInitiatedOn()) + .verifiedOn(kyc.getVerifiedOn()) + .rejectedOn(kyc.getRejectedOn()) + .expiryDate(kyc.getExpiryDate()) + .remarks(kyc.getRemarks()) + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformService.java new file mode 100644 index 000000000..c2cb55844 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformService.java @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.client.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.client.domain.Client; + +public interface ClientKycWritePlatformService { + + /** Initiate a new KYC record for the given client (status: PENDING). */ + CommandProcessingResult initiateKyc(Long clientId, JsonCommand command); + + /** + * Initiates a KYC record inline during client registration. + * The KYC JSON is the nested {@code kyc} object from the client creation payload. + * Runs within the same transaction as the parent client creation. + */ + void initiateKycInline(Client client, String kycJson); + + /** Update PAN / Aadhaar / GSTIN details; triggers IN_PROGRESS status. */ + CommandProcessingResult updateKyc(Long clientId, Long kycId, JsonCommand command); + + /** + * Step 1 of Aadhaar e-KYC: calls LincPay generate-otp with the client's full Aadhaar. + * The full Aadhaar is NOT stored. The returned {@code CommandProcessingResult} includes + * a {@code "aadhaarRefNumber"} changes entry that must be passed back to the client-facing + * app so they can present it in the subsequent verify-otp step. + */ + CommandProcessingResult generateAadhaarOtp(Long clientId, Long kycId, JsonCommand command); + + /** + * Trigger 3rd-party verification for selected document types and, if all pass, + * mark KYC as VERIFIED. + */ + CommandProcessingResult verifyKyc(Long clientId, Long kycId, JsonCommand command); + + /** + * Verify PAN via LincPay and mark panVerified on the KYC record. + * The KYC is NOT auto-rejected on failure – returns an error response so the UI can retry. + */ + CommandProcessingResult verifyPan(Long clientId, Long kycId, JsonCommand command); + + /** + * Step 2 of Aadhaar e-KYC: verify the OTP using refNumber returned by generate-otp. + * Marks aadhaarVerified on the KYC record on success. + */ + CommandProcessingResult verifyAadhaar(Long clientId, Long kycId, JsonCommand command); + + /** + * Verify GSTIN via LincPay and mark gstinVerified on the KYC record. + * The KYC is NOT auto-rejected on failure – returns an error response so the UI can retry. + */ + CommandProcessingResult verifyGstin(Long clientId, Long kycId, JsonCommand command); + + /** Reject the KYC; stores a rejection reason. */ + CommandProcessingResult rejectKyc(Long clientId, Long kycId, JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java new file mode 100644 index 000000000..2b35d63dc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -0,0 +1,460 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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 com.google.gson.JsonParser; +import jakarta.persistence.PersistenceException; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.command.ClientKycCommand; +import org.apache.fineract.portfolio.client.data.ClientKycApiConstants; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientKyc; +import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper; +import org.apache.fineract.portfolio.client.domain.ClientKycStatus; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.apache.fineract.portfolio.client.exception.ClientKycAlreadyExistsException; +import org.apache.fineract.portfolio.client.exception.ClientKycInvalidStateTransitionException; +import org.apache.fineract.portfolio.client.kyc.KycVerificationResult; +import org.apache.fineract.portfolio.client.kyc.KycVerificationService; +import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKycWritePlatformService { + + private static final Logger LOG = LoggerFactory.getLogger(ClientKycWritePlatformServiceJpaRepositoryImpl.class); + + private final PlatformSecurityContext context; + private final ClientRepositoryWrapper clientRepository; + private final ClientKycRepositoryWrapper kycRepositoryWrapper; + private final ClientKycCommandFromApiJsonDeserializer deserializer; + private final KycVerificationService kycVerificationService; + + @Transactional + @Override + public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) { + this.context.authenticatedUser(); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.INITIATE_REQUEST_PARAMS); + kycCommand.validateForInitiate(); + + if (kycRepositoryWrapper.existsByClientId(clientId)) { + throw new ClientKycAlreadyExistsException(clientId); + } + + try { + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = ClientKyc.initiateKyc(client, command); + kycRepositoryWrapper.saveAndFlush(kyc); + + return new CommandProcessingResultBuilder() + .withCommandId(command.commandId()) + .withOfficeId(client.officeId()) + .withClientId(clientId) + .withEntityId(kyc.getId()) + .build(); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + handleDataIntegrityIssues(dve.getMostSpecificCause(), dve); + return CommandProcessingResult.empty(); + } catch (final PersistenceException dve) { + handleDataIntegrityIssues(ExceptionUtils.getRootCause(dve.getCause()), dve); + return CommandProcessingResult.empty(); + } + } + + @Transactional + @Override + public CommandProcessingResult updateKyc(final Long clientId, final Long kycId, final JsonCommand command) { + this.context.authenticatedUser(); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.UPDATE_REQUEST_PARAMS); + kycCommand.validateForUpdate(); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); + + final ClientKycStatus currentStatus = kyc.getKycStatus(); + if (currentStatus.isVerified()) { + throw new ClientKycInvalidStateTransitionException("update", currentStatus.name()); + } + + final Map changes = kyc.update(command); + + if (!changes.isEmpty()) { + // Move to IN_PROGRESS when documents are updated + if (!currentStatus.isInProgress()) { + kyc.markInProgress(); + changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue()); + } + kycRepositoryWrapper.saveAndFlush(kyc); + } + + return new CommandProcessingResultBuilder() + .withCommandId(command.commandId()) + .withOfficeId(client.officeId()) + .withClientId(clientId) + .withEntityId(kycId) + .with(changes) + .build(); + } + + @Transactional + @Override + public CommandProcessingResult generateAadhaarOtp(final Long clientId, final Long kycId, + final JsonCommand command) { + this.context.authenticatedUser(); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.AADHAAR_GENERATE_OTP_REQUEST_PARAMS); + kycCommand.validateForAadhaarGenerateOtp(); + + // Full Aadhaar is used only for the API call – never stored + final KycVerificationResult result = kycVerificationService.generateAadhaarOtp( + kycCommand.getAadhaarNumber()); + + if (!result.isSuccess()) { + throw new PlatformServiceUnavailableException( + "error.msg.client.kyc.aadhaar.otp.generation.failed", + "Aadhaar OTP generation failed: " + result.getErrorMessage(), + result.getErrorMessage()); + } + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + return new CommandProcessingResultBuilder() + .withCommandId(command.commandId()) + .withOfficeId(client.officeId()) + .withClientId(clientId) + .withEntityId(kycId) + .with(Map.of("aadhaarRefNumber", result.getReferenceId())) + .build(); + } + + @Transactional + @Override + public CommandProcessingResult verifyKyc(final Long clientId, final Long kycId, final JsonCommand command) { + this.context.authenticatedUser(); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.VERIFY_REQUEST_PARAMS); + kycCommand.validateForVerify(); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); + + final ClientKycStatus currentStatus = kyc.getKycStatus(); + if (currentStatus.isRejected()) { + throw new ClientKycInvalidStateTransitionException("verify", currentStatus.name()); + } + + // ── 3rd-party verifications ─────────────────────────────────────────── + + if (Boolean.TRUE.equals(kycCommand.getVerifyPan()) && kyc.getPanNumber() != null) { + final String panDob = client.dateOfBirth() != null + ? client.dateOfBirth().format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) : null; + final KycVerificationResult result = kycVerificationService.verifyPan( + kyc.getPanNumber(), client.getDisplayName(), panDob); + if (result.isSuccess()) { + final JsonObject panData = extractPanData(result.getRawResponse()); + kyc.markPanVerified( + getJsonString(panData, "full_name"), + getJsonString(panData, "aadhaar_seeding_status"), + getJsonString(panData, "category"), + getJsonString(panData, "status")); + } else { + LOG.warn("PAN verification failed for client {}: {}", clientId, result.getErrorMessage()); + kyc.markRejected("PAN verification failed: " + result.getErrorMessage()); + kycRepositoryWrapper.saveAndFlush(kyc); + return buildResult(command, client, clientId, kycId, Map.of("kycStatus", kyc.getKycStatusValue())); + } + } + + if (Boolean.TRUE.equals(kycCommand.getVerifyAadhaar())) { + // refNumber and otp are validated in kycCommand.validateForVerify() when verifyAadhaar=true + final KycVerificationResult result = kycVerificationService.verifyAadhaar( + kycCommand.getAadhaarRefNumber(), kycCommand.getAadhaarOtp()); + if (result.isSuccess()) { + kyc.markAadhaarVerified(); + } else { + LOG.warn("Aadhaar OTP verification failed for client {}: {}", clientId, result.getErrorMessage()); + kyc.markRejected("Aadhaar verification failed: " + result.getErrorMessage()); + kycRepositoryWrapper.saveAndFlush(kyc); + return buildResult(command, client, clientId, kycId, Map.of("kycStatus", kyc.getKycStatusValue())); + } + } + + if (Boolean.TRUE.equals(kycCommand.getVerifyGstin()) && kyc.getGstinNumber() != null) { + final KycVerificationResult result = kycVerificationService.verifyGstin(kyc.getGstinNumber()); + if (result.isSuccess()) { + kyc.markGstinVerified(); + } else { + LOG.warn("GSTIN verification failed for client {}: {}", clientId, result.getErrorMessage()); + kyc.markRejected("GSTIN verification failed: " + result.getErrorMessage()); + kycRepositoryWrapper.saveAndFlush(kyc); + return buildResult(command, client, clientId, kycId, Map.of("kycStatus", kyc.getKycStatusValue())); + } + } + + // All requested verifications passed – mark VERIFIED + kyc.markVerified(kycCommand.getCkycNumber()); + kycRepositoryWrapper.saveAndFlush(kyc); + + return buildResult(command, client, clientId, kycId, + Map.of("kycStatus", kyc.getKycStatusValue(), "verifiedOn", kyc.getVerifiedOn())); + } + + @Transactional + @Override + public CommandProcessingResult verifyPan(final Long clientId, final Long kycId, final JsonCommand command) { + this.context.authenticatedUser(); + LOG.info("verifyPan: START clientId={}, kycId={}", clientId, kycId); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.VERIFY_PAN_REQUEST_PARAMS); + kycCommand.validateForVerifyPan(); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); + + LOG.info("verifyPan: client='{}', panNumber='{}', kycStatus={}, dateOfBirth={}", + client.getDisplayName(), kyc.getPanNumber(), kyc.getKycStatus(), client.dateOfBirth()); + + if (kyc.getKycStatus().isRejected()) { + throw new ClientKycInvalidStateTransitionException("verify-pan", kyc.getKycStatus().name()); + } + + if (kyc.getPanNumber() == null) { + throw new PlatformServiceUnavailableException( + "error.msg.client.kyc.pan.not.found", + "PAN number is not set on this KYC record. Update the KYC record first."); + } + + final String dob = client.dateOfBirth() != null + ? client.dateOfBirth().format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) + : null; + LOG.info("verifyPan: calling LincPay with panNumber='{}', name='{}', dob='{}'", + kyc.getPanNumber(), client.getDisplayName(), dob); + + final KycVerificationResult result = kycVerificationService.verifyPan( + kyc.getPanNumber(), client.getDisplayName(), dob); + + LOG.info("verifyPan: LincPay result success={}, referenceId={}, errorMessage={}, rawResponse={}", + result.isSuccess(), result.getReferenceId(), result.getErrorMessage(), result.getRawResponse()); + + if (!result.isSuccess()) { + LOG.error("verifyPan: FAILED for clientId={}, kycId={}, pan={}: {}", + clientId, kycId, kyc.getPanNumber(), result.getErrorMessage()); + throw new PlatformServiceUnavailableException( + "error.msg.client.kyc.pan.verification.failed", + "PAN verification failed: " + result.getErrorMessage(), + result.getErrorMessage()); + } + + final JsonObject panData = extractPanData(result.getRawResponse()); + kyc.markPanVerified( + getJsonString(panData, "full_name"), + getJsonString(panData, "aadhaar_seeding_status"), + getJsonString(panData, "category"), + getJsonString(panData, "status")); + kycRepositoryWrapper.saveAndFlush(kyc); + LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}", clientId, kycId); + + return buildResult(command, client, clientId, kycId, + Map.of("panVerified", true, "panVerifiedOn", kyc.getPanVerifiedOn())); + } + + @Transactional + @Override + public CommandProcessingResult verifyAadhaar(final Long clientId, final Long kycId, final JsonCommand command) { + this.context.authenticatedUser(); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.VERIFY_AADHAAR_REQUEST_PARAMS); + kycCommand.validateForVerifyAadhaar(); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); + + if (kyc.getKycStatus().isRejected()) { + throw new ClientKycInvalidStateTransitionException("verify-aadhaar", kyc.getKycStatus().name()); + } + + final KycVerificationResult result = kycVerificationService.verifyAadhaar( + kycCommand.getAadhaarRefNumber(), kycCommand.getAadhaarOtp()); + + if (!result.isSuccess()) { + throw new PlatformServiceUnavailableException( + "error.msg.client.kyc.aadhaar.verification.failed", + "Aadhaar OTP verification failed: " + result.getErrorMessage(), + result.getErrorMessage()); + } + + kyc.markAadhaarVerified(); + kycRepositoryWrapper.saveAndFlush(kyc); + + return buildResult(command, client, clientId, kycId, + Map.of("aadhaarVerified", true, "aadhaarVerifiedOn", kyc.getAadhaarVerifiedOn())); + } + + @Transactional + @Override + public CommandProcessingResult verifyGstin(final Long clientId, final Long kycId, final JsonCommand command) { + this.context.authenticatedUser(); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.VERIFY_GSTIN_REQUEST_PARAMS); + kycCommand.validateForVerifyGstin(); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); + + if (kyc.getKycStatus().isRejected()) { + throw new ClientKycInvalidStateTransitionException("verify-gstin", kyc.getKycStatus().name()); + } + + if (kyc.getGstinNumber() == null) { + throw new PlatformServiceUnavailableException( + "error.msg.client.kyc.gstin.not.found", + "GSTIN number is not set on this KYC record. Update the KYC record first."); + } + + final KycVerificationResult result = kycVerificationService.verifyGstin(kyc.getGstinNumber()); + + if (!result.isSuccess()) { + throw new PlatformServiceUnavailableException( + "error.msg.client.kyc.gstin.verification.failed", + "GSTIN verification failed: " + result.getErrorMessage(), + result.getErrorMessage()); + } + + kyc.markGstinVerified(); + kycRepositoryWrapper.saveAndFlush(kyc); + + return buildResult(command, client, clientId, kycId, + Map.of("gstinVerified", true, "gstinVerifiedOn", kyc.getGstinVerifiedOn())); + } + + @Transactional + @Override + public CommandProcessingResult rejectKyc(final Long clientId, final Long kycId, final JsonCommand command) { + this.context.authenticatedUser(); + + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + command.json(), ClientKycApiConstants.REJECT_REQUEST_PARAMS); + kycCommand.validateForReject(); + + final Client client = clientRepository.findOneWithNotFoundDetection(clientId); + final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); + + if (kyc.getKycStatus().isRejected()) { + throw new ClientKycInvalidStateTransitionException("reject", kyc.getKycStatus().name()); + } + + kyc.markRejected(kycCommand.getRemarks()); + kycRepositoryWrapper.saveAndFlush(kyc); + + return buildResult(command, client, clientId, kycId, + Map.of("kycStatus", kyc.getKycStatusValue(), "rejectedOn", kyc.getRejectedOn())); + } + + @Override + public void initiateKycInline(final Client client, final String kycJson) { + final ClientKycCommand kycCommand = deserializer.commandFromApiJson( + kycJson, ClientKycApiConstants.INITIATE_REQUEST_PARAMS); + kycCommand.validateForInitiate(); + + if (kycRepositoryWrapper.existsByClientId(client.getId())) { + throw new ClientKycAlreadyExistsException(client.getId()); + } + + final ClientKyc kyc = ClientKyc.initiateKyc(client, + kycCommand.getKycType(), kycCommand.getRiskCategory(), + kycCommand.getPanNumber(), kycCommand.getAadhaarNumberMasked(), + kycCommand.getGstinNumber(), kycCommand.getRemarks()); + kycRepositoryWrapper.saveAndFlush(kyc); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private CommandProcessingResult buildResult(final JsonCommand command, final Client client, + final Long clientId, final Long kycId, final Map changes) { + return new CommandProcessingResultBuilder() + .withCommandId(command.commandId()) + .withOfficeId(client.officeId()) + .withClientId(clientId) + .withEntityId(kycId) + .with(changes) + .build(); + } + + /** + * Extracts the "data" object from a LincPay PAN API response. + * Expected shape: {"code":"200","data":{"full_name":"...","aadhaar_seeding_status":"y","category":"individual","status":"valid",...}} + */ + private JsonObject extractPanData(final String rawResponse) { + if (rawResponse == null || rawResponse.isBlank()) { + return new JsonObject(); + } + try { + final JsonElement root = JsonParser.parseString(rawResponse); + if (root.isJsonObject() && root.getAsJsonObject().has("data") + && root.getAsJsonObject().get("data").isJsonObject()) { + return root.getAsJsonObject().getAsJsonObject("data"); + } + } catch (final Exception e) { + LOG.warn("Failed to parse PAN response JSON: {}", e.getMessage()); + } + return new JsonObject(); + } + + private String getJsonString(final JsonObject json, final String field) { + if (json.has(field) && !json.get(field).isJsonNull()) { + return json.get(field).getAsString(); + } + return null; + } + + private void handleDataIntegrityIssues(final Throwable cause, final Exception dve) { + if (cause != null && cause.getMessage() != null + && cause.getMessage().contains("uq_m_client_kyc_client_id")) { + throw new ClientKycAlreadyExistsException(-1L); + } + LOG.error("Unexpected data integrity issue in KYC.", dve); + throw ErrorHandler.getMappable(dve, "error.msg.client.kyc.unknown.data.integrity.issue", + "Unknown data integrity issue with KYC resource."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index 540ecedeb..b092ee0e8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -124,6 +124,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP private final BusinessEventNotifierService businessEventNotifierService; private final EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService; private final ExternalIdFactory externalIdFactory; + private final ClientKycWritePlatformService clientKycWritePlatformService; @Transactional @Override @@ -329,6 +330,16 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP newClient.getId(), null, command.arrayOfParameterNamed(ClientApiConstants.datatables)); } + // ── Inline KYC creation (optional) ─────────────────────────────── + final JsonElement rootElement = this.fromApiJsonHelper.parse(command.json()); + if (rootElement.getAsJsonObject().has(ClientApiConstants.kycParamName)) { + final JsonElement kycElement = rootElement.getAsJsonObject().get(ClientApiConstants.kycParamName); + if (!kycElement.isJsonNull()) { + this.clientKycWritePlatformService.initiateKycInline(newClient, + this.fromApiJsonHelper.toJson(kycElement)); + } + } + legalForm = LegalForm.fromInt(newClient.getLegalForm()); entityDatatableChecksWritePlatformService.runTheCheck(newClient.getId(), EntityTables.CLIENT.getName(), StatusEnum.CREATE.getValue(), EntityTables.CLIENT.getForeignKeyColumnNameOnDatatable(), legalForm.getLabel()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java new file mode 100644 index 000000000..104df622c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java @@ -0,0 +1,116 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.mobilelookup.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupLogData; +import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult; +import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog; +import org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService; +import org.springframework.stereotype.Component; + +/** + * REST endpoints for DIP / MNRL mobile number lookup. + * + *

+ *   GET /v1/lookup/check?mobileNumber={mobileNumber}                  – Check mobile number
+ *   GET /v1/lookup/logs                                                – Get all lookup logs
+ *   GET /v1/lookup/logs?mobileNumber={num}&status={status}        – Filter logs
+ * 
+ */ +@Path("/v1/lookup") +@Component +@Tag(name = "Mobile Number Lookup", + description = "Check mobile numbers against DIP (Digital Intelligence Platform) suspected numbers " + + "and MNRL (Mobile Number Revocation List) disconnected numbers. " + + "Use during client registration to verify mobile numbers.") +@RequiredArgsConstructor +public class MobileLookupApiResource { + + private final PlatformSecurityContext context; + private final MobileLookupService mobileLookupService; + private final DefaultToApiJsonSerializer resultSerializer; + private final DefaultToApiJsonSerializer listSerializer; + + @GET + @Path("/check") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Check mobile number against DIP and MNRL", + description = "Calls the external DIP/MNRL lookup service to check the given mobile number. " + + "Returns status: CLEAN, SUSPECTED, DISCONNECTED, or SUSPECTED_AND_DISCONNECTED. " + + "Every lookup is logged in the activity log.", + responses = { + @ApiResponse(responseCode = "200", description = "Mobile number lookup result", + content = @Content(schema = @Schema(implementation = MobileLookupResult.class))) + }) + public String checkMobileNumber( + @QueryParam("mobileNumber") @Parameter(description = "Mobile number to check (10-15 digits)") final String mobileNumber) { + this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP"); + final MobileLookupResult result = mobileLookupService.checkMobileNumber(mobileNumber); + return resultSerializer.serialize(result); + } + + @GET + @Path("/logs") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Get mobile number lookup activity logs", + description = "Returns lookup activity logs. Optionally filter by mobile number or status " + + "(CLEAN, SUSPECTED, DISCONNECTED, SUSPECTED_AND_DISCONNECTED, ERROR).", + responses = { + @ApiResponse(responseCode = "200", description = "List of lookup activity logs") + }) + public String getLookupLogs( + @QueryParam("mobileNumber") @Parameter(description = "Filter by mobile number") final String mobileNumber, + @QueryParam("status") @Parameter(description = "Filter by status: CLEAN, SUSPECTED, DISCONNECTED, SUSPECTED_AND_DISCONNECTED, ERROR") final String status) { + this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP"); + + final List logs; + if (mobileNumber != null && !mobileNumber.isBlank()) { + logs = mobileLookupService.getLookupLogsByMobileNumber(mobileNumber.trim()); + } else if (status != null && !status.isBlank()) { + logs = mobileLookupService.getLookupLogsByStatus(status.trim().toUpperCase()); + } else { + logs = mobileLookupService.getLookupLogs(); + } + + final List logDataList = logs.stream() + .map(MobileLookupLogData::fromEntity) + .collect(Collectors.toList()); + + return listSerializer.serialize(logDataList); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupDetailData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupDetailData.java new file mode 100644 index 000000000..a9529dfef --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupDetailData.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.mobilelookup.data; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MobileLookupDetailData { + + private final String dipStatus; + private final String reason; + private final String reportedBy; + + private final String disconnectionReason; + private final String operator; + + public static MobileLookupDetailData forSuspected(String dipStatus, String reason, String reportedBy) { + return new MobileLookupDetailData(dipStatus, reason, reportedBy, null, null); + } + + public static MobileLookupDetailData forDisconnected(String disconnectionReason, String operator) { + return new MobileLookupDetailData(null, null, null, disconnectionReason, operator); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java new file mode 100644 index 000000000..cfa7d7c0e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.mobilelookup.data; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog; + +@Getter +@AllArgsConstructor +public class MobileLookupLogData { + + private final Long id; + private final String mobileNumber; + private final String lookupStatus; + private final boolean suspected; + private final boolean disconnected; + private final String responseMessage; + private final String lookupSource; + private final Long clientId; + private final String lookedUpOn; + + public static MobileLookupLogData fromEntity(final MobileLookupLog log) { + return new MobileLookupLogData( + log.getId(), + log.getMobileNumber(), + log.getLookupStatus(), + log.isSuspected(), + log.isDisconnected(), + log.getResponseMessage(), + log.getLookupSource(), + log.getClientId(), + log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupResult.java new file mode 100644 index 000000000..de8a01dc0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupResult.java @@ -0,0 +1,81 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.mobilelookup.data; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MobileLookupResult { + + private final boolean success; + private final String message; + private final MobileLookupData data; + private final String timestamp; + + @Getter + @AllArgsConstructor + public static class MobileLookupData { + + private final String mobileNumber; + private final String status; + private final boolean suspected; + private final boolean disconnected; + private final MobileLookupDetailData suspectedDetail; + private final MobileLookupDetailData disconnectedDetail; + } + + public static MobileLookupResult clean(String mobileNumber) { + MobileLookupData data = new MobileLookupData(mobileNumber, "CLEAN", false, false, null, null); + return new MobileLookupResult(true, + "Number is CLEAN — not found in DIP or MNRL records", + data, OffsetDateTime.now(ZoneOffset.UTC).toString()); + } + + public static MobileLookupResult suspected(String mobileNumber, MobileLookupDetailData suspectedDetail) { + MobileLookupData data = new MobileLookupData(mobileNumber, "SUSPECTED", true, false, suspectedDetail, null); + return new MobileLookupResult(true, + "Number is SUSPECTED — found in DIP records", + data, OffsetDateTime.now(ZoneOffset.UTC).toString()); + } + + public static MobileLookupResult disconnected(String mobileNumber, MobileLookupDetailData disconnectedDetail) { + MobileLookupData data = new MobileLookupData(mobileNumber, "DISCONNECTED", false, true, null, disconnectedDetail); + return new MobileLookupResult(true, + "Number is DISCONNECTED — found in MNRL records", + data, OffsetDateTime.now(ZoneOffset.UTC).toString()); + } + + public static MobileLookupResult suspectedAndDisconnected(String mobileNumber, + MobileLookupDetailData suspectedDetail, MobileLookupDetailData disconnectedDetail) { + MobileLookupData data = new MobileLookupData(mobileNumber, "SUSPECTED_AND_DISCONNECTED", + true, true, suspectedDetail, disconnectedDetail); + return new MobileLookupResult(true, + "Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", + data, OffsetDateTime.now(ZoneOffset.UTC).toString()); + } + + public static MobileLookupResult serviceUnavailable(String mobileNumber, String message) { + MobileLookupData data = new MobileLookupData(mobileNumber, "SERVICE_UNAVAILABLE", false, false, null, null); + return new MobileLookupResult(false, message, data, OffsetDateTime.now(ZoneOffset.UTC).toString()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java new file mode 100644 index 000000000..e38b9b6ea --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java @@ -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.mobilelookup.service; + +import java.util.List; +import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult; +import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog; + +public interface MobileLookupService { + + MobileLookupResult checkMobileNumber(String mobileNumber); + + MobileLookupResult checkMobileNumber(String mobileNumber, String source, Long clientId); + + List getLookupLogs(); + + List getLookupLogsByMobileNumber(String mobileNumber); + + List getLookupLogsByStatus(String status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java new file mode 100644 index 000000000..9aa59b2e7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java @@ -0,0 +1,219 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.mobilelookup.service; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.List; +import java.util.Set; +import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupDetailData; +import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult; +import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog; +import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +@Service +public class MobileLookupServiceImpl implements MobileLookupService { + + private static final Logger LOG = LoggerFactory.getLogger(MobileLookupServiceImpl.class); + + /** Demo numbers that always return SUSPECTED without calling the external API. */ + private static final Set DEMO_SUSPECTED_NUMBERS = Set.of( + "9827123456", "9827456789", "9826234567", + "9826987654", "9893123456", "9893765432" + ); + + /** Demo numbers that always return CLEAN without calling the external API. */ + private static final Set DEMO_CLEAN_NUMBERS = Set.of( + "9752123456", "9752345678", "9752987654" + ); + + private final String baseUrl; + private final RestTemplate restTemplate; + private final MobileLookupLogRepository logRepository; + + public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl, + MobileLookupLogRepository logRepository) { + this.baseUrl = baseUrl; + this.restTemplate = new RestTemplate(); + this.logRepository = logRepository; + } + + @Override + @Transactional + public MobileLookupResult checkMobileNumber(final String mobileNumber) { + return checkMobileNumber(mobileNumber, "API", null); + } + + @Override + @Transactional + public MobileLookupResult checkMobileNumber(final String mobileNumber, final String source, final Long clientId) { + if (mobileNumber == null || mobileNumber.isBlank()) { + throw new IllegalArgumentException("Mobile number must not be empty"); + } + + final String trimmed = mobileNumber.trim(); + + // Demo numbers — return SUSPECTED without calling the external API + if (isDemoSuspectedNumber(trimmed)) { + LOG.info("Demo suspected number detected: {}", trimmed); + final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected( + "active", "Demo DIP suspected number", "DEMO"); + final MobileLookupResult result = MobileLookupResult.suspected(trimmed, suspectedDetail); + saveLog(trimmed, "SUSPECTED", true, false, + result.getMessage(), null, source, clientId); + return result; + } + + // Demo numbers — return CLEAN without calling the external API + if (isDemoCleanNumber(trimmed)) { + LOG.info("Demo clean number detected: {}", trimmed); + final MobileLookupResult result = MobileLookupResult.clean(trimmed); + saveLog(trimmed, "CLEAN", false, false, + result.getMessage(), null, source, clientId); + return result; + } + + final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed; + + LOG.info("Calling DIP/MNRL lookup API: {}", url); + + String rawResponse = null; + try { + rawResponse = restTemplate.getForObject(url, String.class); + LOG.info("DIP/MNRL lookup response for {}: {}", trimmed, rawResponse); + + final MobileLookupResult result = parseResponse(rawResponse); + + saveLog(trimmed, result.getData().getStatus(), + result.getData().isSuspected(), result.getData().isDisconnected(), + result.getMessage(), rawResponse, source, clientId); + + return result; + } catch (Exception e) { + LOG.error("Failed to call DIP/MNRL lookup API for {}: {}", trimmed, e.getMessage()); + saveLog(trimmed, "ERROR", false, false, + e.getMessage(), rawResponse, source, clientId); + // Return CLEAN with service-unavailable message instead of throwing + return MobileLookupResult.serviceUnavailable(trimmed, + "DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default."); + } + } + + @Override + @Transactional(readOnly = true) + public List getLookupLogs() { + return logRepository.findAllOrderByLookedUpOnDesc(); + } + + @Override + @Transactional(readOnly = true) + public List getLookupLogsByMobileNumber(final String mobileNumber) { + return logRepository.findByMobileNumber(mobileNumber); + } + + @Override + @Transactional(readOnly = true) + public List getLookupLogsByStatus(final String status) { + return logRepository.findByLookupStatus(status); + } + + private void saveLog(final String mobileNumber, final String status, + final boolean suspected, final boolean disconnected, + final String message, final String rawResponse, + final String source, final Long clientId) { + try { + final MobileLookupLog log = new MobileLookupLog( + mobileNumber, status, suspected, disconnected, + message, rawResponse, source, clientId); + logRepository.save(log); + } catch (Exception e) { + LOG.error("Failed to save mobile lookup log for '{}': {}", mobileNumber, e.getMessage()); + } + } + + private MobileLookupResult parseResponse(final String responseJson) { + final JsonObject root = JsonParser.parseString(responseJson).getAsJsonObject(); + + final boolean success = root.get("success").getAsBoolean(); + final String message = root.get("message").getAsString(); + final String timestamp = root.get("timestamp").getAsString(); + + final JsonObject data = root.getAsJsonObject("data"); + final String mobileNumber = data.get("mobileNumber").getAsString(); + final String status = data.get("status").getAsString(); + final boolean suspected = data.get("suspected").getAsBoolean(); + final boolean disconnected = data.get("disconnected").getAsBoolean(); + + MobileLookupDetailData suspectedDetail = null; + MobileLookupDetailData disconnectedDetail = null; + + if (suspected && !data.get("suspectedDetail").isJsonNull()) { + JsonObject sd = data.getAsJsonObject("suspectedDetail"); + suspectedDetail = MobileLookupDetailData.forSuspected( + getStringOrNull(sd, "dipStatus"), + getStringOrNull(sd, "reason"), + getStringOrNull(sd, "reportedBy")); + } + + if (disconnected && !data.get("disconnectedDetail").isJsonNull()) { + JsonObject dd = data.getAsJsonObject("disconnectedDetail"); + disconnectedDetail = MobileLookupDetailData.forDisconnected( + getStringOrNull(dd, "disconnectionReason"), + getStringOrNull(dd, "operator")); + } + + MobileLookupResult.MobileLookupData lookupData = new MobileLookupResult.MobileLookupData( + mobileNumber, status, suspected, disconnected, suspectedDetail, disconnectedDetail); + + return new MobileLookupResult(success, message, lookupData, timestamp); + } + + private boolean isDemoCleanNumber(final String mobileNumber) { + if (DEMO_CLEAN_NUMBERS.contains(mobileNumber)) { + return true; + } + if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) { + return DEMO_CLEAN_NUMBERS.contains(mobileNumber.substring(2)); + } + return false; + } + + private boolean isDemoSuspectedNumber(final String mobileNumber) { + // Check with and without country code prefix (91) + if (DEMO_SUSPECTED_NUMBERS.contains(mobileNumber)) { + return true; + } + if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) { + return DEMO_SUSPECTED_NUMBERS.contains(mobileNumber.substring(2)); + } + return false; + } + + private String getStringOrNull(JsonObject json, String key) { + JsonElement el = json.get(key); + return (el != null && !el.isJsonNull()) ? el.getAsString() : null; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java new file mode 100644 index 000000000..2a1e6cb79 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java @@ -0,0 +1,194 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.sanctions.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningLogData; +import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLog; +import org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService; +import org.apache.fineract.portfolio.sanctions.service.SanctionsXmlLoaderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * REST endpoints for sanctions screening against the UN Security Council consolidated list. + * + *
+ *   GET    /v1/sanctions/screen?name={name}            – Screen a name against the sanctions list
+ *   POST   /v1/sanctions/screen                        – Screen a name (JSON body: {"name": "..."})
+ *   POST   /v1/sanctions/load                          – Load sanctions data from XML
+ *   GET    /v1/sanctions/status                        – Get sanctions database status (count)
+ *   GET    /v1/sanctions/logs                          – Get all screening activity logs
+ *   GET    /v1/sanctions/logs?result=FLAGGED           – Filter logs by result
+ *   GET    /v1/sanctions/logs?name=searchTerm          – Search logs by screened name
+ * 
+ */ +@Path("/v1/sanctions") +@Component +@Tag(name = "Sanctions Screening", + description = "Screen client names against the UN Security Council consolidated sanctions list. " + + "Supports both individual and entity screening with full activity logging.") +@RequiredArgsConstructor +public class SanctionsScreeningApiResource { + + private static final Logger LOG = LoggerFactory.getLogger(SanctionsScreeningApiResource.class); + + private final PlatformSecurityContext context; + private final SanctionsScreeningService sanctionsScreeningService; + private final SanctionsXmlLoaderService sanctionsXmlLoaderService; + private final DefaultToApiJsonSerializer screeningResultSerializer; + private final DefaultToApiJsonSerializer mapSerializer; + private final DefaultToApiJsonSerializer listSerializer; + + @GET + @Path("/screen") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Screen a name against sanctions list", + description = "Checks the given name against the UN Security Council consolidated sanctions list. " + + "Returns FLAGGED status with match details if found, or CLEAR status if no match. " + + "Every screening is logged in the activity log.", + responses = { + @ApiResponse(responseCode = "200", description = "Screening result", + content = @Content(schema = @Schema(implementation = SanctionsScreeningResult.class))) + }) + public String screenNameGet( + @QueryParam("name") @Parameter(description = "Name to screen against sanctions list") final String name) { + this.context.authenticatedUser().validateHasReadPermission("SANCTIONS"); + final SanctionsScreeningResult result = sanctionsScreeningService.screenName(name, "API", null, null); + return screeningResultSerializer.serialize(result); + } + + @POST + @Path("/screen") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Screen a name against sanctions list (POST)", + description = "Accepts JSON body with 'name' field. Checks against the UN Security Council consolidated sanctions list. " + + "Every screening is logged in the activity log.", + responses = { + @ApiResponse(responseCode = "200", description = "Screening result", + content = @Content(schema = @Schema(implementation = SanctionsScreeningResult.class))) + }) + public String screenNamePost(final String apiRequestBodyAsJson) { + this.context.authenticatedUser().validateHasReadPermission("SANCTIONS"); + + final com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject(); + final String name = json.has("name") ? json.get("name").getAsString() : null; + final String source = json.has("source") ? json.get("source").getAsString() : "CLIENT_CREATION"; + final Long clientId = json.has("clientId") ? json.get("clientId").getAsLong() : null; + + final SanctionsScreeningResult result = sanctionsScreeningService.screenName(name, source, clientId, null); + return screeningResultSerializer.serialize(result); + } + + @POST + @Path("/load") + @Consumes({ MediaType.APPLICATION_XML, MediaType.APPLICATION_OCTET_STREAM }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Load sanctions data from UN XML", + description = "Parses the UN Security Council consolidated sanctions list XML and loads all " + + "individuals and entities into the database.", + responses = { + @ApiResponse(responseCode = "200", description = "Load result with count of entries loaded") + }) + public String loadSanctionsXml(final InputStream xmlInputStream) { + this.context.authenticatedUser().validateHasPermissionTo("LOAD_SANCTIONS"); + + LOG.info("Loading sanctions data from XML..."); + final int count = sanctionsXmlLoaderService.loadFromXml(xmlInputStream); + + final Map response = new HashMap<>(); + response.put("message", "Sanctions data loaded successfully"); + response.put("entriesLoaded", count); + response.put("totalActive", sanctionsScreeningService.getSanctionsCount()); + return mapSerializer.serialize(response); + } + + @GET + @Path("/status") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Get sanctions database status", + description = "Returns the count of active sanctions entries in the database.", + responses = { + @ApiResponse(responseCode = "200", description = "Sanctions database status") + }) + public String getSanctionsStatus() { + this.context.authenticatedUser().validateHasReadPermission("SANCTIONS"); + + final Map response = new HashMap<>(); + response.put("totalActiveEntries", sanctionsScreeningService.getSanctionsCount()); + response.put("status", "ACTIVE"); + return mapSerializer.serialize(response); + } + + @GET + @Path("/logs") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Get sanctions screening activity logs", + description = "Returns the screening activity logs. Optionally filter by result (FLAGGED/CLEAR) or search by name.", + responses = { + @ApiResponse(responseCode = "200", description = "List of screening activity logs") + }) + public String getScreeningLogs( + @QueryParam("result") @Parameter(description = "Filter by result: FLAGGED or CLEAR") final String result, + @QueryParam("name") @Parameter(description = "Search logs by screened name") final String name) { + this.context.authenticatedUser().validateHasReadPermission("SANCTIONS"); + + final List logs; + if (name != null && !name.isBlank()) { + logs = sanctionsScreeningService.searchScreeningLogs(name); + } else if (result != null && !result.isBlank()) { + logs = sanctionsScreeningService.getScreeningLogsByResult(result.toUpperCase()); + } else { + logs = sanctionsScreeningService.getScreeningLogs(); + } + + final List logDataList = logs.stream() + .map(SanctionsScreeningLogData::fromEntity) + .collect(Collectors.toList()); + + return listSerializer.serialize(logDataList); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsMatchData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsMatchData.java new file mode 100644 index 000000000..d5c0d1d52 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsMatchData.java @@ -0,0 +1,76 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sanctions.data; + +import java.time.LocalDate; +import java.util.List; + +/** + * Data for a single sanctions match returned in the screening result. + */ +public final class SanctionsMatchData { + + private final Long id; + private final String dataId; + private final String entryType; + private final String fullName; + private final String unListType; + private final String referenceNumber; + private final LocalDate listedOn; + private final String gender; + private final String nationality; + private final String dateOfBirth; + private final String placeOfBirth; + private final String comments; + private final List aliases; + + public SanctionsMatchData(final Long id, final String dataId, final String entryType, + final String fullName, final String unListType, final String referenceNumber, + final LocalDate listedOn, final String gender, final String nationality, + final String dateOfBirth, final String placeOfBirth, final String comments, + final List aliases) { + this.id = id; + this.dataId = dataId; + this.entryType = entryType; + this.fullName = fullName; + this.unListType = unListType; + this.referenceNumber = referenceNumber; + this.listedOn = listedOn; + this.gender = gender; + this.nationality = nationality; + this.dateOfBirth = dateOfBirth; + this.placeOfBirth = placeOfBirth; + this.comments = comments; + this.aliases = aliases; + } + + public Long getId() { return id; } + public String getDataId() { return dataId; } + public String getEntryType() { return entryType; } + public String getFullName() { return fullName; } + public String getUnListType() { return unListType; } + public String getReferenceNumber() { return referenceNumber; } + public LocalDate getListedOn() { return listedOn; } + public String getGender() { return gender; } + public String getNationality() { return nationality; } + public String getDateOfBirth() { return dateOfBirth; } + public String getPlaceOfBirth() { return placeOfBirth; } + public String getComments() { return comments; } + public List getAliases() { return aliases; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java new file mode 100644 index 000000000..12b6ef728 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java @@ -0,0 +1,85 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.sanctions.data; + +import java.time.OffsetDateTime; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLog; + +/** + * Read-only data transfer object for sanctions screening activity log entries. + */ +public final class SanctionsScreeningLogData { + + private final Long id; + private final String screenedName; + private final String screeningResult; + private final Integer matchCount; + private final String matchedEntryIds; + private final String matchedNames; + private final String screeningSource; + private final Long clientId; + private final String ipAddress; + private final OffsetDateTime screenedOn; + private final Long screenedBy; + + public SanctionsScreeningLogData(final Long id, final String screenedName, final String screeningResult, + final Integer matchCount, final String matchedEntryIds, final String matchedNames, + final String screeningSource, final Long clientId, final String ipAddress, + final OffsetDateTime screenedOn, final Long screenedBy) { + this.id = id; + this.screenedName = screenedName; + this.screeningResult = screeningResult; + this.matchCount = matchCount; + this.matchedEntryIds = matchedEntryIds; + this.matchedNames = matchedNames; + this.screeningSource = screeningSource; + this.clientId = clientId; + this.ipAddress = ipAddress; + this.screenedOn = screenedOn; + this.screenedBy = screenedBy; + } + + public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) { + return new SanctionsScreeningLogData( + log.getId(), + log.getScreenedName(), + log.getScreeningResult(), + log.getMatchCount(), + log.getMatchedEntryIds(), + log.getMatchedNames(), + log.getScreeningSource(), + log.getClientId(), + log.getIpAddress(), + log.getScreenedOn(), + log.getCreatedBy().orElse(null) + ); + } + + public Long getId() { return id; } + public String getScreenedName() { return screenedName; } + public String getScreeningResult() { return screeningResult; } + public Integer getMatchCount() { return matchCount; } + public String getMatchedEntryIds() { return matchedEntryIds; } + public String getMatchedNames() { return matchedNames; } + public String getScreeningSource() { return screeningSource; } + public Long getClientId() { return clientId; } + public String getIpAddress() { return ipAddress; } + public OffsetDateTime getScreenedOn() { return screenedOn; } + public Long getScreenedBy() { return screenedBy; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java new file mode 100644 index 000000000..3cccd28de --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningResult.java @@ -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.sanctions.data; + +import java.util.List; + +/** + * Response object for sanctions screening API. + */ +public final class SanctionsScreeningResult { + + private final String screenedName; + private final boolean matchFound; + private final String status; + private final String message; + private final int matchCount; + private final List matches; + + private SanctionsScreeningResult(final String screenedName, final boolean matchFound, + final String status, final String message, final int matchCount, + final List matches) { + this.screenedName = screenedName; + this.matchFound = matchFound; + this.status = status; + this.message = message; + this.matchCount = matchCount; + this.matches = matches; + } + + public static SanctionsScreeningResult matched(final String screenedName, + final List matches) { + return new SanctionsScreeningResult(screenedName, true, "FLAGGED", + "Potential match found in UN Security Council consolidated sanctions list. " + + "Manual review required before proceeding.", + matches.size(), matches); + } + + public static SanctionsScreeningResult clear(final String screenedName) { + return new SanctionsScreeningResult(screenedName, false, "CLEAR", + "No match found in UN Security Council consolidated sanctions list.", + 0, List.of()); + } + + public String getScreenedName() { return screenedName; } + public boolean isMatchFound() { return matchFound; } + public String getStatus() { return status; } + public String getMessage() { return message; } + public int getMatchCount() { return matchCount; } + public List getMatches() { return matches; } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java new file mode 100644 index 000000000..838edc7ed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sanctions.service; + +import java.util.List; +import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLog; + +public interface SanctionsScreeningService { + + /** + * Screen a client name against the UN consolidated sanctions list. + * Logs the activity automatically. + */ + SanctionsScreeningResult screenName(String name); + + /** + * Screen a client name with source context for activity logging. + * + * @param name the name to screen + * @param source screening source (CLIENT_CREATION, MANUAL_CHECK, API) + * @param clientId optional client ID if screening during client onboarding + * @param ipAddress optional IP address of the requester + */ + SanctionsScreeningResult screenName(String name, String source, Long clientId, String ipAddress); + + /** + * Get the total count of active sanctions entries. + */ + long getSanctionsCount(); + + /** + * Get all screening activity logs (most recent first). + */ + List getScreeningLogs(); + + /** + * Get screening logs filtered by result (FLAGGED or CLEAR). + */ + List getScreeningLogsByResult(String result); + + /** + * Search screening logs by screened name. + */ + List searchScreeningLogs(String name); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java new file mode 100644 index 000000000..634ccb7c5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java @@ -0,0 +1,148 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.sanctions.service; + +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.sanctions.data.SanctionsMatchData; +import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsAlias; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsEntry; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsEntryRepository; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLog; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SanctionsScreeningServiceImpl implements SanctionsScreeningService { + + private static final Logger LOG = LoggerFactory.getLogger(SanctionsScreeningServiceImpl.class); + + private final SanctionsEntryRepository sanctionsEntryRepository; + private final SanctionsScreeningLogRepository screeningLogRepository; + + @Override + @Transactional + public SanctionsScreeningResult screenName(final String name) { + return screenName(name, "API", null, null); + } + + @Override + @Transactional + public SanctionsScreeningResult screenName(final String name, final String source, + final Long clientId, final String ipAddress) { + if (name == null || name.isBlank()) { + saveLog(name, "CLEAR", 0, null, null, source, clientId, ipAddress); + return SanctionsScreeningResult.clear(name); + } + + final String trimmedName = name.trim(); + LOG.info("Screening name against sanctions list: {}", trimmedName); + + final List matches = sanctionsEntryRepository.searchByNameOrAlias(trimmedName); + + if (matches.isEmpty()) { + LOG.info("No sanctions match found for: {}", trimmedName); + saveLog(trimmedName, "CLEAR", 0, null, null, source, clientId, ipAddress); + return SanctionsScreeningResult.clear(trimmedName); + } + + LOG.warn("Sanctions match found for '{}': {} potential match(es)", trimmedName, matches.size()); + + final List matchDataList = matches.stream() + .map(this::toMatchData) + .collect(Collectors.toList()); + + // Build matched IDs and names for the log + final String matchedEntryIds = matches.stream() + .map(e -> String.valueOf(e.getId())) + .collect(Collectors.joining(",")); + final String matchedNames = matches.stream() + .map(SanctionsEntry::getFullName) + .collect(Collectors.joining(", ")); + + saveLog(trimmedName, "FLAGGED", matches.size(), matchedEntryIds, matchedNames, source, clientId, ipAddress); + + return SanctionsScreeningResult.matched(trimmedName, matchDataList); + } + + @Override + @Transactional(readOnly = true) + public long getSanctionsCount() { + return sanctionsEntryRepository.countByActiveTrue(); + } + + @Override + @Transactional(readOnly = true) + public List getScreeningLogs() { + return screeningLogRepository.findAllOrderByScreenedOnDesc(); + } + + @Override + @Transactional(readOnly = true) + public List getScreeningLogsByResult(final String result) { + return screeningLogRepository.findByScreeningResult(result); + } + + @Override + @Transactional(readOnly = true) + public List searchScreeningLogs(final String name) { + return screeningLogRepository.findByScreenedNameContaining(name); + } + + private void saveLog(final String screenedName, final String result, final int matchCount, + final String matchedEntryIds, final String matchedNames, final String source, + final Long clientId, final String ipAddress) { + try { + final SanctionsScreeningLog log = new SanctionsScreeningLog( + screenedName, result, matchCount, matchedEntryIds, matchedNames, + source, clientId, ipAddress); + screeningLogRepository.save(log); + } catch (Exception e) { + LOG.error("Failed to save sanctions screening log for '{}': {}", screenedName, e.getMessage()); + } + } + + private SanctionsMatchData toMatchData(final SanctionsEntry entry) { + final List aliases = entry.getAliases().stream() + .map(SanctionsAlias::getAliasName) + .collect(Collectors.toList()); + + return new SanctionsMatchData( + entry.getId(), + entry.getDataId(), + entry.getEntryType(), + entry.getFullName(), + entry.getUnListType(), + entry.getReferenceNumber(), + entry.getListedOn(), + entry.getGender(), + entry.getNationality(), + entry.getDateOfBirth(), + entry.getPlaceOfBirth(), + entry.getComments(), + aliases + ); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java new file mode 100644 index 000000000..1381e7ff5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java @@ -0,0 +1,276 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.sanctions.service; + +import java.io.InputStream; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsAlias; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsEntry; +import org.apache.fineract.portfolio.sanctions.domain.SanctionsEntryRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** + * Parses the UN Security Council consolidated sanctions list XML and loads entries into the database. + */ +@Service +@RequiredArgsConstructor +public class SanctionsXmlLoaderService { + + private static final Logger LOG = LoggerFactory.getLogger(SanctionsXmlLoaderService.class); + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final SanctionsEntryRepository sanctionsEntryRepository; + + /** + * Load sanctions data from XML input stream. + * Parses both INDIVIDUALS and ENTITIES sections. + * + * @param xmlInputStream the XML input stream containing the UN consolidated sanctions list + * @return count of entries loaded + */ + @Transactional + public int loadFromXml(final InputStream xmlInputStream) { + try { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + // Disable external entities for security + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + final DocumentBuilder builder = factory.newDocumentBuilder(); + final Document document = builder.parse(xmlInputStream); + document.getDocumentElement().normalize(); + + final List allEntries = new ArrayList<>(); + + // Parse INDIVIDUALS + final NodeList individuals = document.getElementsByTagName("INDIVIDUAL"); + LOG.info("Found {} INDIVIDUAL entries in XML", individuals.getLength()); + for (int i = 0; i < individuals.getLength(); i++) { + final Element element = (Element) individuals.item(i); + final SanctionsEntry entry = parseIndividual(element); + if (entry != null) { + allEntries.add(entry); + } + } + + // Parse ENTITIES + final NodeList entities = document.getElementsByTagName("ENTITY"); + LOG.info("Found {} ENTITY entries in XML", entities.getLength()); + for (int i = 0; i < entities.getLength(); i++) { + final Element element = (Element) entities.item(i); + final SanctionsEntry entry = parseEntity(element); + if (entry != null) { + allEntries.add(entry); + } + } + + // Save in batches + final int batchSize = 100; + for (int i = 0; i < allEntries.size(); i += batchSize) { + final int end = Math.min(i + batchSize, allEntries.size()); + sanctionsEntryRepository.saveAll(allEntries.subList(i, end)); + sanctionsEntryRepository.flush(); + } + + LOG.info("Successfully loaded {} sanctions entries into database", allEntries.size()); + return allEntries.size(); + + } catch (Exception e) { + LOG.error("Failed to parse sanctions XML", e); + throw new RuntimeException("Failed to load sanctions data from XML: " + e.getMessage(), e); + } + } + + private SanctionsEntry parseIndividual(final Element element) { + try { + final String dataId = getTextContent(element, "DATAID"); + final String firstName = getTextContent(element, "FIRST_NAME"); + final String secondName = getTextContent(element, "SECOND_NAME"); + final String thirdName = getTextContent(element, "THIRD_NAME"); + final String fourthName = getTextContent(element, "FOURTH_NAME"); + final String unListType = getTextContent(element, "UN_LIST_TYPE"); + final String referenceNumber = getTextContent(element, "REFERENCE_NUMBER"); + final LocalDate listedOn = parseDate(getTextContent(element, "LISTED_ON")); + final String gender = getTextContent(element, "GENDER"); + final String comments = getTextContent(element, "COMMENTS1"); + final String designation = getTextContent(element, "DESIGNATION"); + + // Collect nationality from NATIONALITY elements + final String nationality = collectNationality(element, "NATIONALITY"); + + // Collect date of birth info + final String dateOfBirth = collectDateOfBirth(element); + + // Collect place of birth info + final String placeOfBirth = collectPlaceOfBirth(element); + + final SanctionsEntry entry = new SanctionsEntry(dataId, "INDIVIDUAL", firstName, + secondName, thirdName, fourthName, unListType, referenceNumber, listedOn, + gender, nationality, comments, designation, dateOfBirth, placeOfBirth); + + // Parse aliases + final NodeList aliasList = element.getElementsByTagName("INDIVIDUAL_ALIAS"); + for (int i = 0; i < aliasList.getLength(); i++) { + final Element aliasElement = (Element) aliasList.item(i); + final String quality = getTextContent(aliasElement, "QUALITY"); + final String aliasName = getTextContent(aliasElement, "ALIAS_NAME"); + if (aliasName != null && !aliasName.isBlank()) { + entry.addAlias(new SanctionsAlias(quality, aliasName)); + } + } + + return entry; + } catch (Exception e) { + LOG.warn("Failed to parse INDIVIDUAL element, skipping", e); + return null; + } + } + + private SanctionsEntry parseEntity(final Element element) { + try { + final String dataId = getTextContent(element, "DATAID"); + final String firstName = getTextContent(element, "FIRST_NAME"); + final String unListType = getTextContent(element, "UN_LIST_TYPE"); + final String referenceNumber = getTextContent(element, "REFERENCE_NUMBER"); + final LocalDate listedOn = parseDate(getTextContent(element, "LISTED_ON")); + final String comments = getTextContent(element, "COMMENTS1"); + + final SanctionsEntry entry = new SanctionsEntry(dataId, "ENTITY", firstName, + null, null, null, unListType, referenceNumber, listedOn, + null, null, comments, null, null, null); + + // Parse entity aliases + final NodeList aliasList = element.getElementsByTagName("ENTITY_ALIAS"); + for (int i = 0; i < aliasList.getLength(); i++) { + final Element aliasElement = (Element) aliasList.item(i); + final String quality = getTextContent(aliasElement, "QUALITY"); + final String aliasName = getTextContent(aliasElement, "ALIAS_NAME"); + if (aliasName != null && !aliasName.isBlank()) { + entry.addAlias(new SanctionsAlias(quality, aliasName)); + } + } + + return entry; + } catch (Exception e) { + LOG.warn("Failed to parse ENTITY element, skipping", e); + return null; + } + } + + private String getTextContent(final Element parent, final String tagName) { + final NodeList nodeList = parent.getElementsByTagName(tagName); + if (nodeList.getLength() > 0) { + final String text = nodeList.item(0).getTextContent(); + return (text != null && !text.isBlank()) ? text.trim() : null; + } + return null; + } + + private LocalDate parseDate(final String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return null; + } + try { + return LocalDate.parse(dateStr, DATE_FORMAT); + } catch (DateTimeParseException e) { + // Try other common formats + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("dd/MM/yyyy")); + } catch (DateTimeParseException e2) { + LOG.debug("Could not parse date: {}", dateStr); + return null; + } + } + } + + private String collectNationality(final Element parent, final String tagName) { + final NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() == 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < nodes.getLength(); i++) { + final Element natElem = (Element) nodes.item(i); + final String value = getTextContent(natElem, "VALUE"); + if (value != null) { + if (sb.length() > 0) { sb.append(", "); } + sb.append(value); + } + } + return sb.length() > 0 ? sb.toString() : null; + } + + private String collectDateOfBirth(final Element parent) { + final NodeList nodes = parent.getElementsByTagName("INDIVIDUAL_DATE_OF_BIRTH"); + if (nodes.getLength() == 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < nodes.getLength(); i++) { + final Element dobElem = (Element) nodes.item(i); + final String type = getTextContent(dobElem, "TYPE_OF_DATE"); + final String date = getTextContent(dobElem, "DATE"); + final String year = getTextContent(dobElem, "YEAR"); + if (sb.length() > 0) { sb.append("; "); } + if (date != null) { + sb.append(date); + } else if (year != null) { + sb.append(year); + } + if (type != null) { + sb.append(" (").append(type).append(")"); + } + } + return sb.length() > 0 ? sb.toString() : null; + } + + private String collectPlaceOfBirth(final Element parent) { + final NodeList nodes = parent.getElementsByTagName("INDIVIDUAL_PLACE_OF_BIRTH"); + if (nodes.getLength() == 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < nodes.getLength(); i++) { + final Element pobElem = (Element) nodes.item(i); + final String city = getTextContent(pobElem, "CITY"); + final String country = getTextContent(pobElem, "COUNTRY"); + final String state = getTextContent(pobElem, "STATE_PROVINCE"); + if (sb.length() > 0) { sb.append("; "); } + List parts = new ArrayList<>(); + if (city != null) { parts.add(city); } + if (state != null) { parts.add(state); } + if (country != null) { parts.add(country); } + sb.append(String.join(", ", parts)); + } + return sb.length() > 0 ? sb.toString() : null; + } +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 671f2373d..6052b791e 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -42,9 +42,9 @@ fineract.security.oauth2.client.registrations.frontend-client.redirect-uris=${FI fineract.security.oauth2.client.registrations.frontend-client.require-authorization-consent=${FINERACT_SECURITY_OAUTH2_CLIENTS_FRONTEND_CONSENT:false} fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} -fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:3306} -fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:root} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:mysql} +fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} +fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:root} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -402,10 +402,10 @@ server.tomcat.max-keep-alive-requests=${FINERACT_SERVER_TOMCAT_MAX_KEEP_ALIVE_RE server.tomcat.threads.max=${FINERACT_SERVER_TOMCAT_THREADS_MAX:200} server.tomcat.threads.min-spare=${FINERACT_SERVER_TOMCAT_THREADS_MIN_SPARE:10} server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABLED:false} -spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.mariadb.jdbc.Driver} -spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:mariadb://localhost:3306/fineract_tenants} -spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:root} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:mysql} +spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} +spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} +spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:root} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} @@ -640,3 +640,25 @@ resilience4j.retry.instances.commandAuditFallback.wait-duration=${FINERACT_COMMA resilience4j.retry.instances.commandAuditFallback.enable-exponential-backoff=${FINERACT_COMMAND_AUDIT_FALLBACK_RETRY_ENABLE_EXPONENTIAL_BACKOFF:true} resilience4j.retry.instances.commandAuditFallback.exponential-backoff-multiplier=${FINERACT_COMMAND_AUDIT_FALLBACK_RETRY_EXPONENTIAL_BACKOFF_MULTIPLIER:2} resilience4j.retry.instances.commandAuditFallback.retryExceptions=${FINERACT_COMMAND_AUDIT_FALLBACK_RETRY_EXCEPTIONS:org.springframework.dao.ConcurrencyFailureException,org.eclipse.persistence.exceptions.OptimisticLockException,jakarta.persistence.OptimisticLockException,org.springframework.orm.jpa.JpaOptimisticLockingFailureException} + + +# ── LincPay eKYC API ────────────────────────────────────────────────────────── +# Set FINERACT_KYC_LINCPAY_API_KEY and FINERACT_KYC_LINCPAY_SECRET as env vars +# in production. Never commit real credentials to source control. +fineract.kyc.lincpay.api-key=${FINERACT_KYC_LINCPAY_API_KEY:api_7c9e3a1f6d4b2a8c5e1f3d7b9a2c6e4f} +fineract.kyc.lincpay.secret=${FINERACT_KYC_LINCPAY_SECRET:secret_4e8a2d7c1f9b3a6e5d2c8f1a7b4e9c3d} +fineract.kyc.lincpay.base-url=${FINERACT_KYC_LINCPAY_BASE_URL:https://ekyc.lincpay.in/EKYC/ekyc} +fineract.kyc.lincpay.path.aadhaar-generate-otp=${FINERACT_KYC_LINCPAY_PATH_AADHAAR_GENERATE_OTP:/aadhaar/generate-otp} +fineract.kyc.lincpay.path.aadhaar-verify-otp=${FINERACT_KYC_LINCPAY_PATH_AADHAAR_VERIFY_OTP:/aadhaar/verify-otp} +fineract.kyc.lincpay.path.pan-validate=${FINERACT_KYC_LINCPAY_PATH_PAN_VALIDATE:/validate-pan} +fineract.kyc.lincpay.path.gstin-validate=${FINERACT_KYC_LINCPAY_PATH_GSTIN_VALIDATE:/validate-gstn} + +# ── OpenProject Integration ────────────────────────────────────────────────── +# Set FINERACT_OPENPROJECT_API_TOKEN as env var in production. +fineract.openproject.enabled=${FINERACT_OPENPROJECT_ENABLED:true} +fineract.openproject.base-url=${FINERACT_OPENPROJECT_BASE_URL:https://Sm.godemo.in} +fineract.openproject.project-id=${FINERACT_OPENPROJECT_PROJECT_ID:cbsnexora} +fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c7274e3ed3d5ca82e1e45e040c857160975087694f4d39ff596} + +# ── DIP / MNRL Mobile Number Lookup ────────────────────────────────────────── +fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0216_add_client_kyc_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0216_add_client_kyc_tables.xml new file mode 100644 index 000000000..cd2c6f046 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0216_add_client_kyc_tables.xml @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_CLIENTKYC' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0217_add_nominee_fields_to_family_members.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0217_add_nominee_fields_to_family_members.xml new file mode 100644 index 000000000..d1c4dd940 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0217_add_nominee_fields_to_family_members.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0218_add_pan_response_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0218_add_pan_response_fields.xml new file mode 100644 index 000000000..6767f7e8d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0218_add_pan_response_fields.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0219_add_rbi_feed_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0219_add_rbi_feed_tables.xml new file mode 100644 index 000000000..49b9cc5e2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0219_add_rbi_feed_tables.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0220_add_openproject_permissions.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0220_add_openproject_permissions.xml new file mode 100644 index 000000000..de5c53b5b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0220_add_openproject_permissions.xml @@ -0,0 +1,47 @@ + + + + + + Add OpenProject read permission + + + + + + + + + + + Add OpenProject create/write permission + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0221_add_openproject_wp_id_to_rbi_feed.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0221_add_openproject_wp_id_to_rbi_feed.xml new file mode 100644 index 000000000..235f8e64c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0221_add_openproject_wp_id_to_rbi_feed.xml @@ -0,0 +1,40 @@ + + + + + + Add OpenProject work package ID and status columns to RBI feed items for tracking + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_sanctions_screening_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_sanctions_screening_tables.xml new file mode 100644 index 000000000..81ba62c58 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0222_add_sanctions_screening_tables.xml @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_SANCTIONS' + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0223_add_sanctions_screening_log.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0223_add_sanctions_screening_log.xml new file mode 100644 index 000000000..4bbbbf259 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0223_add_sanctions_screening_log.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0224_add_mobile_lookup_log.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0224_add_mobile_lookup_log.xml new file mode 100644 index 000000000..d145c2332 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0224_add_mobile_lookup_log.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'READ_MOBILE_LOOKUP' + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/logback-spring.xml b/fineract-provider/src/main/resources/logback-spring.xml index 84fe601f2..eb705f339 100644 --- a/fineract-provider/src/main/resources/logback-spring.xml +++ b/fineract-provider/src/main/resources/logback-spring.xml @@ -35,10 +35,25 @@ + + + logs/fineract.log + + logs/fineract.%d{yyyy-MM-dd}.log + 30 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} : %msg%n + + + + + + @@ -61,6 +76,7 @@ but it's still possible to override this using java -Dlogging.level.root=info -jar fineract-provider.jar, as per https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-custom-log-levels --> + diff --git a/sql/kyc/create_kyc_tables_mysql_mariadb.sql b/sql/kyc/create_kyc_tables_mysql_mariadb.sql new file mode 100644 index 000000000..2cb95813b --- /dev/null +++ b/sql/kyc/create_kyc_tables_mysql_mariadb.sql @@ -0,0 +1,139 @@ +-- ============================================================================= +-- KYC Module – Table Creation Script (MySQL / MariaDB) +-- ============================================================================= +-- DB Connection (from config/docker/env/fineract-common.env & mariadb.env): +-- Host : localhost (or 'db' inside Docker) +-- Port : 3306 +-- Database : fineract_default ← tenant database (your actual data lives here) +-- Username : root +-- Password : skdcnwauicn2ucnaecasdsajdnizucawencascdca +-- +-- Run command: +-- mysql -h localhost -P 3306 -u root -p'skdcnwauicn2ucnaecasdsajdnizucawencascdca' fineract_default < create_kyc_tables_mysql_mariadb.sql +-- ============================================================================= + +USE fineract_default; + +-- ============================================================================= +-- TABLE: m_client_kyc +-- One KYC record per client (enforced by UNIQUE on client_id). +-- Stores PAN, Aadhaar (masked), GSTIN, CKYCRR number, risk category, +-- lifecycle dates and audit fields. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS `m_client_kyc` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `client_id` BIGINT NOT NULL, + + -- kyc_status : 100=PENDING | 200=IN_PROGRESS | 300=VERIFIED | 400=REJECTED | 500=EXPIRED + `kyc_status` SMALLINT NOT NULL DEFAULT 100, + + -- kyc_type : 1=INDIVIDUAL | 2=NON_INDIVIDUAL + `kyc_type` SMALLINT NOT NULL, + + -- risk_category : 1=LOW (re-KYC 10yr) | 2=MEDIUM (8yr) | 3=HIGH (2yr) + `risk_category` SMALLINT NOT NULL DEFAULT 1, + + -- PAN (Permanent Account Number – Income Tax Dept) + -- Format: 5 uppercase letters + 4 digits + 1 uppercase letter e.g. ABCDE1234F + `pan_number` VARCHAR(10) NULL, + `pan_verified` TINYINT(1) NOT NULL DEFAULT 0, + `pan_verified_on` DATE NULL, + + -- Aadhaar – ONLY masked form stored (last 4 digits visible) per UIDAI directive + -- e.g. XXXXXXXX1234 + `aadhaar_number_masked` VARCHAR(12) NULL, + `aadhaar_verified` TINYINT(1) NOT NULL DEFAULT 0, + `aadhaar_verified_on` DATE NULL, + + -- GSTIN (15-char GST Identification Number – mandatory for NON_INDIVIDUAL) + -- Format: 2-digit state code + 10-char PAN + 1 entity num + Z + check digit + `gstin_number` VARCHAR(15) NULL, + `gstin_verified` TINYINT(1) NOT NULL DEFAULT 0, + `gstin_verified_on` DATE NULL, + + -- CKYCRR – Central KYC Records Registry number (issued by CERSAI, 14 digits) + `ckyc_number` VARCHAR(14) NULL, + + -- Lifecycle timestamps + `initiated_on` DATE NULL, + `verified_on` DATE NULL, + `rejected_on` DATE NULL, + -- expiry_date computed as: verified_on + risk_category years offset + `expiry_date` DATE NULL, + `remarks` VARCHAR(500) NULL, + + -- Audit fields (AbstractAuditableWithUTCDateTimeCustom) + `created_by` BIGINT NOT NULL, + `created_on_utc` DATETIME NOT NULL, + `last_modified_by` BIGINT NOT NULL, + `last_modified_on_utc` DATETIME NOT NULL, + `version` BIGINT NOT NULL DEFAULT 0, + + PRIMARY KEY (`id`), + UNIQUE KEY `uq_m_client_kyc_client_id` (`client_id`), + CONSTRAINT `FK_m_client_kyc_client_id` + FOREIGN KEY (`client_id`) REFERENCES `m_client` (`id`) + ON DELETE RESTRICT ON UPDATE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- TABLE: m_client_kyc_verification_log +-- Immutable audit trail for every 3rd-party PAN / Aadhaar / GSTIN API call. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS `m_client_kyc_verification_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `kyc_id` BIGINT NOT NULL, + + -- verification_type: PAN | AADHAAR | GSTIN + `verification_type` VARCHAR(20) NOT NULL, + + -- verification_status: SUCCESS | FAILURE | PENDING | ERROR + `verification_status` VARCHAR(20) NOT NULL, + + -- External reference id returned by the 3rd-party vendor API + `request_reference` VARCHAR(100) NULL, + + -- Full raw JSON response from the vendor – stored for compliance audit + `api_response` TEXT NULL, + `remarks` VARCHAR(500) NULL, + + -- Audit fields + `created_by` BIGINT NOT NULL, + `created_on_utc` DATETIME NOT NULL, + `last_modified_by` BIGINT NOT NULL, + `last_modified_on_utc` DATETIME NOT NULL, + `version` BIGINT NOT NULL DEFAULT 0, + + PRIMARY KEY (`id`), + CONSTRAINT `FK_m_kyc_verification_log_kyc_id` + FOREIGN KEY (`kyc_id`) REFERENCES `m_client_kyc` (`id`) + ON DELETE CASCADE ON UPDATE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +-- ============================================================================= +-- PERMISSIONS – Insert into m_permission +-- ============================================================================= + +INSERT IGNORE INTO `m_permission` + (`grouping`, `code`, `entity_name`, `action_name`, `can_maker_checker`) +VALUES + ('portfolio', 'READ_CLIENTKYC', 'CLIENTKYC', 'READ', 0), + ('portfolio', 'CREATE_CLIENTKYC', 'CLIENTKYC', 'CREATE', 1), + ('portfolio', 'UPDATE_CLIENTKYC', 'CLIENTKYC', 'UPDATE', 1), + ('portfolio', 'VERIFY_CLIENTKYC', 'CLIENTKYC', 'VERIFY', 1), + ('portfolio', 'REJECT_CLIENTKYC', 'CLIENTKYC', 'REJECT', 1); + + +-- ============================================================================= +-- VERIFY CREATION +-- ============================================================================= + +SELECT 'Tables created:' AS info; +SHOW TABLES LIKE 'm_client_kyc%'; + +SELECT 'Permissions inserted:' AS info; +SELECT code, entity_name, action_name FROM m_permission WHERE entity_name = 'CLIENTKYC'; diff --git a/sql/kyc/create_kyc_tables_postgresql.sql b/sql/kyc/create_kyc_tables_postgresql.sql new file mode 100644 index 000000000..8505c4fd1 --- /dev/null +++ b/sql/kyc/create_kyc_tables_postgresql.sql @@ -0,0 +1,155 @@ +-- ============================================================================= +-- KYC Module – Table Creation Script (PostgreSQL) +-- ============================================================================= +-- DB Connection (from config/docker/env/postgresql.env & application.properties): +-- Host : localhost +-- Port : 5432 +-- Database : fineract_default ← tenant database (your actual data lives here) +-- Username : postgres +-- Password : skdcnwauicn2ucnaecasdsajdnizucawencascdca +-- +-- Run command: +-- psql -h localhost -p 5432 -U postgres -d fineract_default -f create_kyc_tables_postgresql.sql +-- +-- (It will prompt for password: skdcnwauicn2ucnaecasdsajdnizucawencascdca) +-- ============================================================================= + +\connect fineract_default; + +-- ============================================================================= +-- TABLE: m_client_kyc +-- One KYC record per client (enforced by UNIQUE on client_id). +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS m_client_kyc ( + id BIGSERIAL NOT NULL, + client_id BIGINT NOT NULL, + + -- kyc_status : 100=PENDING | 200=IN_PROGRESS | 300=VERIFIED | 400=REJECTED | 500=EXPIRED + kyc_status SMALLINT NOT NULL DEFAULT 100, + + -- kyc_type : 1=INDIVIDUAL | 2=NON_INDIVIDUAL + kyc_type SMALLINT NOT NULL, + + -- risk_category : 1=LOW (re-KYC 10yr) | 2=MEDIUM (8yr) | 3=HIGH (2yr) + risk_category SMALLINT NOT NULL DEFAULT 1, + + -- PAN (Permanent Account Number – Income Tax Dept) + -- Format: 5 uppercase letters + 4 digits + 1 uppercase letter e.g. ABCDE1234F + pan_number VARCHAR(10), + pan_verified BOOLEAN NOT NULL DEFAULT FALSE, + pan_verified_on DATE, + + -- Aadhaar – ONLY masked form stored (last 4 digits visible) per UIDAI directive + aadhaar_number_masked VARCHAR(12), + aadhaar_verified BOOLEAN NOT NULL DEFAULT FALSE, + aadhaar_verified_on DATE, + + -- GSTIN (15-char GST Identification Number – mandatory for NON_INDIVIDUAL) + gstin_number VARCHAR(15), + gstin_verified BOOLEAN NOT NULL DEFAULT FALSE, + gstin_verified_on DATE, + + -- CKYCRR – Central KYC Records Registry number (issued by CERSAI, 14 digits) + ckyc_number VARCHAR(14), + + -- Lifecycle timestamps + initiated_on DATE, + verified_on DATE, + rejected_on DATE, + expiry_date DATE, + remarks VARCHAR(500), + + -- Audit fields (AbstractAuditableWithUTCDateTimeCustom) + created_by BIGINT NOT NULL, + created_on_utc TIMESTAMP WITH TIME ZONE NOT NULL, + last_modified_by BIGINT NOT NULL, + last_modified_on_utc TIMESTAMP WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT pk_m_client_kyc PRIMARY KEY (id), + CONSTRAINT uq_m_client_kyc_client_id UNIQUE (client_id), + CONSTRAINT fk_m_client_kyc_client_id + FOREIGN KEY (client_id) REFERENCES m_client(id) + ON DELETE RESTRICT ON UPDATE RESTRICT +); + +COMMENT ON TABLE m_client_kyc IS 'RBI-mandated KYC record – one per client'; +COMMENT ON COLUMN m_client_kyc.kyc_status IS '100=PENDING,200=IN_PROGRESS,300=VERIFIED,400=REJECTED,500=EXPIRED'; +COMMENT ON COLUMN m_client_kyc.kyc_type IS '1=INDIVIDUAL, 2=NON_INDIVIDUAL'; +COMMENT ON COLUMN m_client_kyc.risk_category IS '1=LOW(10yr), 2=MEDIUM(8yr), 3=HIGH(2yr) re-KYC period'; +COMMENT ON COLUMN m_client_kyc.aadhaar_number_masked IS 'Masked Aadhaar: first 8 digits X, last 4 visible. Full number must NOT be stored per UIDAI directive.'; + + +-- ============================================================================= +-- TABLE: m_client_kyc_verification_log +-- Immutable audit trail for every 3rd-party API call. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS m_client_kyc_verification_log ( + id BIGSERIAL NOT NULL, + kyc_id BIGINT NOT NULL, + + -- verification_type: PAN | AADHAAR | GSTIN + verification_type VARCHAR(20) NOT NULL, + + -- verification_status: SUCCESS | FAILURE | PENDING | ERROR + verification_status VARCHAR(20) NOT NULL, + + request_reference VARCHAR(100), + api_response TEXT, + remarks VARCHAR(500), + + -- Audit fields + created_by BIGINT NOT NULL, + created_on_utc TIMESTAMP WITH TIME ZONE NOT NULL, + last_modified_by BIGINT NOT NULL, + last_modified_on_utc TIMESTAMP WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT pk_m_client_kyc_verification_log PRIMARY KEY (id), + CONSTRAINT fk_m_kyc_verification_log_kyc_id + FOREIGN KEY (kyc_id) REFERENCES m_client_kyc(id) + ON DELETE CASCADE ON UPDATE RESTRICT +); + +COMMENT ON TABLE m_client_kyc_verification_log IS 'Audit log of all 3rd-party KYC API calls (PAN/Aadhaar/GSTIN)'; + + +-- ============================================================================= +-- PERMISSIONS +-- ============================================================================= + +INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) +SELECT 'portfolio', 'READ_CLIENTKYC', 'CLIENTKYC', 'READ', FALSE +WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CLIENTKYC'); + +INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) +SELECT 'portfolio', 'CREATE_CLIENTKYC', 'CLIENTKYC', 'CREATE', TRUE +WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CLIENTKYC'); + +INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) +SELECT 'portfolio', 'UPDATE_CLIENTKYC', 'CLIENTKYC', 'UPDATE', TRUE +WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CLIENTKYC'); + +INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) +SELECT 'portfolio', 'VERIFY_CLIENTKYC', 'CLIENTKYC', 'VERIFY', TRUE +WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'VERIFY_CLIENTKYC'); + +INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) +SELECT 'portfolio', 'REJECT_CLIENTKYC', 'CLIENTKYC', 'REJECT', TRUE +WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REJECT_CLIENTKYC'); + + +-- ============================================================================= +-- VERIFY CREATION +-- ============================================================================= + +SELECT tablename AS "Tables created" +FROM pg_tables +WHERE schemaname = 'public' + AND tablename LIKE 'm_client_kyc%'; + +SELECT code, entity_name, action_name +FROM m_permission +WHERE entity_name = 'CLIENTKYC'; diff --git a/sql/seed_sample_data.sql b/sql/seed_sample_data.sql new file mode 100644 index 000000000..512b59988 --- /dev/null +++ b/sql/seed_sample_data.sql @@ -0,0 +1,521 @@ +-- ============================================================ +-- Fineract Sample Data Seed Script (PostgreSQL) +-- Database: fineract_default +-- Run with: psql -U postgres -d fineract_default -f seed_sample_data.sql +-- ============================================================ +-- NOTE: Fineract typically bootstraps with: +-- m_office id=1 (Head Office), m_appuser id=1 (admin) +-- Run this AFTER Fineract has started at least once so +-- the base schema + default data exist. +-- ============================================================ + +BEGIN; + +-- ============================================================ +-- 1. OFFICES (id 2-5, Head Office id=1 already exists) +-- ============================================================ +INSERT INTO m_office (id, parent_id, hierarchy, external_id, name, opening_date, created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + (2, 1, '.1.2.', 'OFF-MUM-001', 'Mumbai Branch', '2024-01-15', 1, NOW(), 1, NOW()), + (3, 1, '.1.3.', 'OFF-DEL-001', 'Delhi Branch', '2024-02-01', 1, NOW(), 1, NOW()), + (4, 1, '.1.4.', 'OFF-BLR-001', 'Bangalore Branch', '2024-03-10', 1, NOW(), 1, NOW()), + (5, 2, '.1.2.5.', 'OFF-MUM-SUB', 'Mumbai Sub-Branch', '2024-06-01', 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_office_id_seq', GREATEST((SELECT MAX(id) FROM m_office), 5)); + +-- ============================================================ +-- 2. STAFF (5 staff members across offices) +-- ============================================================ +INSERT INTO m_staff (id, is_loan_officer, office_id, firstname, lastname, display_name, mobile_no, external_id, is_active, joining_date, email_address, created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + (1, true, 2, 'Rajesh', 'Sharma', 'Sharma, Rajesh', '9876543210', 'STF-001', true, '2024-01-20', 'rajesh.sharma@cbs.in', 1, NOW(), 1, NOW()), + (2, true, 3, 'Priya', 'Patel', 'Patel, Priya', '9876543211', 'STF-002', true, '2024-02-10', 'priya.patel@cbs.in', 1, NOW(), 1, NOW()), + (3, true, 4, 'Amit', 'Kumar', 'Kumar, Amit', '9876543212', 'STF-003', true, '2024-03-15', 'amit.kumar@cbs.in', 1, NOW(), 1, NOW()), + (4, false, 2, 'Sneha', 'Reddy', 'Reddy, Sneha', '9876543213', 'STF-004', true, '2024-04-01', 'sneha.reddy@cbs.in', 1, NOW(), 1, NOW()), + (5, true, 5, 'Vikram', 'Singh', 'Singh, Vikram', '9876543214', 'STF-005', true, '2024-06-15', 'vikram.singh@cbs.in', 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_staff_id_seq', GREATEST((SELECT MAX(id) FROM m_staff), 5)); + +-- ============================================================ +-- 3. CLIENTS (5 clients — 4 individuals, 1 entity) +-- ============================================================ +INSERT INTO m_client (id, account_no, external_id, status_enum, activation_date, office_joining_date, office_id, staff_id, + firstname, middlename, lastname, fullname, display_name, mobile_no, is_staff, + date_of_birth, email_address, legal_form_enum, + submittedon_date, submittedon_userid, activatedon_userid, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + (1, '000000001', 'CLI-IND-001', 300, '2024-04-01', '2024-04-01', 2, 1, + 'Arun', NULL, 'Mehta', NULL, 'Arun Mehta', '9100000001', false, + '1990-05-15', 'arun.mehta@gmail.com', 1, + '2024-03-25', 1, 1, + 1, NOW(), 1, NOW()), + + (2, '000000002', 'CLI-IND-002', 300, '2024-04-10', '2024-04-10', 3, 2, + 'Kavita', 'Devi', 'Joshi', NULL, 'Kavita Devi Joshi', '9100000002', false, + '1985-08-22', 'kavita.joshi@gmail.com', 1, + '2024-04-05', 1, 1, + 1, NOW(), 1, NOW()), + + (3, '000000003', 'CLI-IND-003', 300, '2024-05-01', '2024-05-01', 4, 3, + 'Suresh', NULL, 'Iyer', NULL, 'Suresh Iyer', '9100000003', false, + '1978-12-10', 'suresh.iyer@yahoo.com', 1, + '2024-04-20', 1, 1, + 1, NOW(), 1, NOW()), + + (4, '000000004', 'CLI-IND-004', 300, '2024-06-01', '2024-06-01', 5, 5, + 'Neha', NULL, 'Gupta', NULL, 'Neha Gupta', '9100000004', false, + '1995-03-30', 'neha.gupta@gmail.com', 1, + '2024-05-25', 1, 1, + 1, NOW(), 1, NOW()), + + -- Non-individual (Entity / Company) client + (5, '000000005', 'CLI-ENT-001', 300, '2024-07-01', '2024-07-01', 2, 4, + NULL, NULL, NULL, 'TechNova Solutions Pvt Ltd', 'TechNova Solutions Pvt Ltd', '9100000005', false, + NULL, 'info@technova.in', 2, + '2024-06-20', 1, 1, + 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_client_id_seq', GREATEST((SELECT MAX(id) FROM m_client), 5)); + +-- ============================================================ +-- 4. GROUPS (2 centers + 3 groups) +-- ============================================================ +-- First ensure group levels exist (Fineract default: 1=Center, 2=Group) +INSERT INTO m_group_level (id, parent_id, super_parent, level_name, recursable, can_have_clients) +VALUES + (1, NULL, true, 'Center', true, false), + (2, 1, false, 'Group', false, true) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO m_group (id, external_id, status_enum, activation_date, office_id, staff_id, parent_id, level_id, display_name, hierarchy, account_no, + submittedon_date, submittedon_userid, activatedon_userid, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + -- Centers (level_id=1) + (1, 'CTR-MUM-001', 300, '2024-04-01', 2, 1, NULL, 1, 'Mumbai Center 1', '.1.', 'G000000001', + '2024-03-25', 1, 1, 1, NOW(), 1, NOW()), + (2, 'CTR-DEL-001', 300, '2024-04-10', 3, 2, NULL, 1, 'Delhi Center 1', '.2.', 'G000000002', + '2024-04-05', 1, 1, 1, NOW(), 1, NOW()), + -- Groups under centers (level_id=2) + (3, 'GRP-MUM-001', 300, '2024-04-01', 2, 1, 1, 2, 'Mumbai SHG Group A', '.1.3.', 'G000000003', + '2024-03-25', 1, 1, 1, NOW(), 1, NOW()), + (4, 'GRP-MUM-002', 300, '2024-04-01', 2, 1, 1, 2, 'Mumbai SHG Group B', '.1.4.', 'G000000004', + '2024-03-25', 1, 1, 1, NOW(), 1, NOW()), + (5, 'GRP-DEL-001', 300, '2024-04-10', 3, 2, 2, 2, 'Delhi Mahila Group', '.2.5.', 'G000000005', + '2024-04-05', 1, 1, 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_group_id_seq', GREATEST((SELECT MAX(id) FROM m_group), 5)); + +-- Link clients to groups +INSERT INTO m_group_client (group_id, client_id) +VALUES + (3, 1), -- Arun → Mumbai SHG Group A + (3, 4), -- Neha → Mumbai SHG Group A + (4, 5), -- TechNova → Mumbai SHG Group B + (5, 2), -- Kavita → Delhi Mahila Group + (5, 3) -- Suresh → Delhi Mahila Group +ON CONFLICT DO NOTHING; + +-- ============================================================ +-- 5. SAVINGS PRODUCTS (4 products) +-- ============================================================ +INSERT INTO m_savings_product (id, name, short_name, description, deposit_type_enum, + currency_code, currency_digits, currency_multiplesof, + nominal_annual_interest_rate, interest_compounding_period_enum, + interest_posting_period_enum, interest_calculation_type_enum, + interest_calculation_days_in_year_type_enum, + min_required_opening_balance, lockin_period_frequency, lockin_period_frequency_enum, + accounting_type, withdrawal_fee_for_transfer, allow_overdraft, + overdraft_limit, nominal_annual_interest_rate_overdraft, + min_overdraft_for_interest_calculation, enforce_min_required_balance, + min_required_balance, withhold_tax, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + (1, 'Regular Savings Account', 'RESA', 'Basic savings account with 4% interest', 100, + 'INR', 2, 1, + 4.000000, 1, -- daily compounding + 4, -- monthly posting + 1, -- daily balance + 365, -- 365 days/year + 500.000000, NULL, NULL, + 1, -- NONE accounting + true, false, NULL, 0, 0, false, NULL, false, + 1, NOW(), 1, NOW()), + + (2, 'Fixed Deposit 1 Year', 'FD1Y', '1-year FD at 7% p.a.', 200, + 'INR', 2, 1, + 7.000000, 4, -- quarterly compounding + 4, -- monthly posting + 1, 365, + 10000.000000, 12, 2, -- 12 month lock-in + 1, true, false, NULL, 0, 0, false, NULL, false, + 1, NOW(), 1, NOW()), + + (3, 'Jan Dhan Yojana Savings', 'JDYS', 'PMJDY zero-balance savings', 100, + 'INR', 2, 1, + 3.500000, 1, 4, 1, 365, + 0.000000, NULL, NULL, + 1, true, true, 10000.000000, 0, 0, false, NULL, false, + 1, NOW(), 1, NOW()), + + (4, 'Business Current Account', 'BUCA', 'Current account for businesses, no interest', 100, + 'INR', 2, 1, + 0.000000, 1, 4, 1, 365, + 25000.000000, NULL, NULL, + 1, true, true, 50000.000000, 0, 0, true, 10000.000000, false, + 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_savings_product_id_seq', GREATEST((SELECT MAX(id) FROM m_savings_product), 4)); + +-- ============================================================ +-- 6. SAVINGS ACCOUNTS (5 accounts linked to clients) +-- ============================================================ +INSERT INTO m_savings_account (id, account_no, external_id, client_id, group_id, product_id, field_officer_id, + status_enum, sub_status_enum, account_type_enum, deposit_type_enum, + submittedon_date, submittedon_userid, approvedon_date, approvedon_userid, + activatedon_date, activatedon_userid, + currency_code, currency_digits, currency_multiplesof, + nominal_annual_interest_rate, interest_compounding_period_enum, + interest_posting_period_enum, interest_calculation_type_enum, + interest_calculation_days_in_year_type_enum, + min_required_opening_balance, withdrawal_fee_for_transfer, + allow_overdraft, overdraft_limit, + nominal_annual_interest_rate_overdraft, min_overdraft_for_interest_calculation, + account_balance_derived, enforce_min_required_balance, + total_deposits_derived, total_withdrawals_derived, + total_overdraft_interest_derived, version, withhold_tax, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + -- Arun Mehta → Regular Savings + (1, 'SA000000001', 'SA-IND-001', 1, NULL, 1, 1, + 300, 0, 1, 100, + '2024-04-01', 1, '2024-04-02', 1, '2024-04-02', 1, + 'INR', 2, 1, + 4.000000, 1, 4, 1, 365, + 500.000000, true, false, NULL, 0, 0, + 25000.000000, false, + 30000.000000, 5000.000000, 0, 1, false, + 1, NOW(), 1, NOW()), + + -- Kavita Joshi → Regular Savings + (2, 'SA000000002', 'SA-IND-002', 2, NULL, 1, 2, + 300, 0, 1, 100, + '2024-04-10', 1, '2024-04-11', 1, '2024-04-11', 1, + 'INR', 2, 1, + 4.000000, 1, 4, 1, 365, + 500.000000, true, false, NULL, 0, 0, + 15000.000000, false, + 18000.000000, 3000.000000, 0, 1, false, + 1, NOW(), 1, NOW()), + + -- Suresh Iyer → Jan Dhan Yojana Savings + (3, 'SA000000003', 'SA-IND-003', 3, NULL, 3, 3, + 300, 0, 1, 100, + '2024-05-01', 1, '2024-05-02', 1, '2024-05-02', 1, + 'INR', 2, 1, + 3.500000, 1, 4, 1, 365, + 0.000000, true, true, 10000.000000, 0, 0, + 2500.000000, false, + 5000.000000, 2500.000000, 0, 1, false, + 1, NOW(), 1, NOW()), + + -- Neha Gupta → Fixed Deposit + (4, 'SA000000004', 'SA-IND-004', 4, NULL, 2, 5, + 300, 0, 1, 200, + '2024-06-01', 1, '2024-06-02', 1, '2024-06-02', 1, + 'INR', 2, 1, + 7.000000, 4, 4, 1, 365, + 10000.000000, true, false, NULL, 0, 0, + 100000.000000, false, + 100000.000000, 0.000000, 0, 1, false, + 1, NOW(), 1, NOW()), + + -- TechNova → Business Current Account + (5, 'SA000000005', 'SA-ENT-001', 5, NULL, 4, 4, + 300, 0, 1, 100, + '2024-07-01', 1, '2024-07-02', 1, '2024-07-02', 1, + 'INR', 2, 1, + 0.000000, 1, 4, 1, 365, + 25000.000000, true, true, 50000.000000, 0, 0, + 350000.000000, true, + 500000.000000, 150000.000000, 0, 1, false, + 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_savings_account_id_seq', GREATEST((SELECT MAX(id) FROM m_savings_account), 5)); + +-- ============================================================ +-- 7. LOAN PRODUCTS (2 products — needed before loans) +-- ============================================================ +INSERT INTO m_product_loan (id, short_name, name, description, + fund_id, currency_code, currency_digits, currency_multiplesof, + principal_amount, min_principal_amount, max_principal_amount, + arrearstolerance_amount, + nominal_interest_rate_per_period, interest_period_frequency_enum, + annual_nominal_interest_rate, interest_method_enum, + interest_calculated_in_period_enum, allow_partial_period_interest_calcualtion, + repay_every, repayment_period_frequency_enum, number_of_repayments, + min_number_of_repayments, max_number_of_repayments, + amortization_method_enum, + accounting_type, loan_transaction_strategy_id, + grace_on_arrears_ageing, + days_in_month_enum, days_in_year_enum, + interest_recalculation_enabled, is_equal_amortization, + can_define_fixed_emi_amount, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + (1, 'MSME', 'MSME Business Loan', 'Loan for small businesses up to 10L at 12% p.a.', + NULL, 'INR', 2, 1, + 500000.000000, 50000.000000, 1000000.000000, + NULL, + 1.000000, 2, -- 1% per month + 12.000000, 0, -- flat interest + 1, false, + 1, 2, 12, -- monthly repay, 12 installments + 6, 24, + 1, -- equal installments + 1, NULL, NULL, + 1, 1, false, false, false, + 1, NOW(), 1, NOW()), + + (2, 'PERL', 'Personal Loan', 'Personal loan up to 5L at 14% p.a.', + NULL, 'INR', 2, 1, + 200000.000000, 25000.000000, 500000.000000, + NULL, + 14.000000, 3, -- 14% per year + 14.000000, 1, -- declining balance + 1, false, + 1, 2, 24, -- monthly repay, 24 installments + 6, 36, + 1, 1, NULL, NULL, + 1, 1, false, false, false, + 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_product_loan_id_seq', GREATEST((SELECT MAX(id) FROM m_product_loan), 2)); + +-- ============================================================ +-- 8. LOANS (4 loans for clients) +-- ============================================================ +INSERT INTO m_loan (id, account_no, external_id, client_id, group_id, product_id, + loan_officer_id, loan_status_id, loan_type_enum, + currency_code, currency_digits, currency_multiplesof, + principal_amount_proposed, principal_amount, approved_principal, net_disbursal_amount, + nominal_interest_rate_per_period, interest_period_frequency_enum, + annual_nominal_interest_rate, interest_method_enum, + interest_calculated_in_period_enum, allow_partial_period_interest_calcualtion, + term_frequency, term_period_frequency_enum, repay_every, repayment_period_frequency_enum, + number_of_repayments, amortization_method_enum, + submittedon_date, submittedon_userid, + approvedon_date, approvedon_userid, + expected_disbursedon_date, disbursedon_date, disbursedon_userid, + expected_maturedon_date, + principal_disbursed_derived, principal_outstanding_derived, + total_expected_repayment_derived, total_outstanding_derived, + interest_rate_differential, days_in_month_enum, days_in_year_enum, + interest_recalculation_enabled, is_npa, is_topup, is_equal_amortization, + version, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + -- Arun Mehta → MSME Loan (Active/Disbursed, status=300) + (1, 'LN000000001', 'LN-IND-001', 1, NULL, 1, + 1, 300, 1, + 'INR', 2, 1, + 500000.000000, 500000.000000, 500000.000000, 500000.000000, + 1.000000, 2, 12.000000, 0, 1, false, + 12, 2, 1, 2, 12, 1, + '2024-05-01', 1, '2024-05-05', 1, + '2024-05-10', '2024-05-10', 1, + '2025-05-10', + 500000.000000, 350000.000000, + 560000.000000, 410000.000000, + 0, 1, 1, false, false, false, false, 1, + 1, NOW(), 1, NOW()), + + -- Kavita Joshi → Personal Loan (Active/Disbursed, status=300) + (2, 'LN000000002', 'LN-IND-002', 2, NULL, 2, + 2, 300, 1, + 'INR', 2, 1, + 200000.000000, 200000.000000, 200000.000000, 200000.000000, + 14.000000, 3, 14.000000, 1, 1, false, + 24, 2, 1, 2, 24, 1, + '2024-05-15', 1, '2024-05-20', 1, + '2024-06-01', '2024-06-01', 1, + '2026-06-01', + 200000.000000, 160000.000000, + 228000.000000, 188000.000000, + 0, 1, 1, false, false, false, false, 1, + 1, NOW(), 1, NOW()), + + -- Suresh Iyer → MSME Loan (Approved not yet disbursed, status=200) + (3, 'LN000000003', 'LN-IND-003', 3, NULL, 1, + 3, 200, 1, + 'INR', 2, 1, + 300000.000000, 300000.000000, 300000.000000, 300000.000000, + 1.000000, 2, 12.000000, 0, 1, false, + 12, 2, 1, 2, 12, 1, + '2024-07-01', 1, '2024-07-10', 1, + '2024-07-15', NULL, NULL, + '2025-07-15', + 0, 0, + 0, 0, + 0, 1, 1, false, false, false, false, 1, + 1, NOW(), 1, NOW()), + + -- TechNova → MSME Loan (Active/Disbursed, status=300) + (4, 'LN000000004', 'LN-ENT-001', 5, NULL, 1, + 4, 300, 1, + 'INR', 2, 1, + 1000000.000000, 1000000.000000, 1000000.000000, 1000000.000000, + 1.000000, 2, 12.000000, 0, 1, false, + 12, 2, 1, 2, 12, 1, + '2024-08-01', 1, '2024-08-05', 1, + '2024-08-10', '2024-08-10', 1, + '2025-08-10', + 1000000.000000, 750000.000000, + 1120000.000000, 870000.000000, + 0, 1, 1, false, false, false, false, 1, + 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_loan_id_seq', GREATEST((SELECT MAX(id) FROM m_loan), 4)); + +-- ============================================================ +-- 9. CLIENT KYC (5 KYC records — one per client) +-- ============================================================ +INSERT INTO m_client_kyc (id, client_id, kyc_status, kyc_type, risk_category, + pan_number, pan_verified, pan_verified_on, + aadhaar_number_masked, aadhaar_verified, aadhaar_verified_on, + gstin_number, gstin_verified, gstin_verified_on, + ckyc_number, initiated_on, verified_on, rejected_on, expiry_date, remarks, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + -- Arun Mehta — Fully verified individual, LOW risk + (1, 1, 300, 1, 1, + 'ABCPM1234A', true, '2024-04-02', + 'XXXX XXXX 5678', true, '2024-04-03', + NULL, false, NULL, + 'CKYC00001234', '2024-04-01', '2024-04-03', NULL, '2034-04-03', 'KYC completed via LincPay eKYC', + 1, NOW(), 1, NOW()), + + -- Kavita Joshi — PAN verified, Aadhaar pending, IN_PROGRESS + (2, 2, 200, 1, 2, + 'BCDPJ5678B', true, '2024-04-11', + NULL, false, NULL, + NULL, false, NULL, + NULL, '2024-04-10', NULL, NULL, NULL, 'Aadhaar OTP verification pending', + 1, NOW(), 1, NOW()), + + -- Suresh Iyer — Fully verified individual, MEDIUM risk + (3, 3, 300, 1, 2, + 'CDEPI9012C', true, '2024-05-02', + 'XXXX XXXX 1234', true, '2024-05-03', + NULL, false, NULL, + 'CKYC00005678', '2024-05-01', '2024-05-03', NULL, '2032-05-03', 'KYC completed — medium risk due to PEP flag', + 1, NOW(), 1, NOW()), + + -- Neha Gupta — PENDING (just initiated) + (4, 4, 100, 1, 1, + NULL, false, NULL, + NULL, false, NULL, + NULL, false, NULL, + NULL, '2024-06-01', NULL, NULL, NULL, 'KYC initiated, documents pending', + 1, NOW(), 1, NOW()), + + -- TechNova Solutions — Non-individual, verified with GSTIN, HIGH risk + (5, 5, 300, 2, 3, + 'AABCT1234E', true, '2024-07-02', + NULL, false, NULL, + '27AABCT1234E1Z5', true, '2024-07-03', + 'CKYC00009999', '2024-07-01', '2024-07-03', NULL, '2026-07-03', 'Non-individual KYC via GSTIN + PAN. High risk — new entity.', + 1, NOW(), 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_client_kyc_id_seq', GREATEST((SELECT MAX(id) FROM m_client_kyc), 5)); + +-- ============================================================ +-- 10. KYC VERIFICATION LOG (audit trail of eKYC API calls) +-- ============================================================ +INSERT INTO m_client_kyc_verification_log (id, kyc_id, verification_type, verification_status, + request_reference, api_response, remarks, + created_by, created_on_utc, last_modified_by, last_modified_on_utc) +VALUES + -- Arun's PAN verification + (1, 1, 'PAN', 'SUCCESS', + 'LNCP-PAN-20240402-001', '{"status":"success","pan":"ABCPM1234A","name":"ARUN MEHTA","valid":true}', + 'PAN verified via LincPay', + 1, '2024-04-02 10:30:00+05:30', 1, '2024-04-02 10:30:00+05:30'), + + -- Arun's Aadhaar OTP generation + (2, 1, 'AADHAAR', 'SUCCESS', + 'LNCP-AAD-20240403-001', '{"status":"success","ref_number":"AAD-REF-12345","message":"OTP sent"}', + 'Aadhaar OTP sent successfully', + 1, '2024-04-03 11:00:00+05:30', 1, '2024-04-03 11:00:00+05:30'), + + -- Arun's Aadhaar OTP verification + (3, 1, 'AADHAAR', 'SUCCESS', + 'LNCP-AAD-20240403-002', '{"status":"success","aadhaar_last4":"5678","name":"Arun Mehta","verified":true}', + 'Aadhaar verified via OTP', + 1, '2024-04-03 11:05:00+05:30', 1, '2024-04-03 11:05:00+05:30'), + + -- Kavita's PAN verification + (4, 2, 'PAN', 'SUCCESS', + 'LNCP-PAN-20240411-001', '{"status":"success","pan":"BCDPJ5678B","name":"KAVITA JOSHI","valid":true}', + 'PAN verified via LincPay', + 1, '2024-04-11 14:20:00+05:30', 1, '2024-04-11 14:20:00+05:30'), + + -- Kavita's Aadhaar OTP — FAILED (wrong number) + (5, 2, 'AADHAAR', 'FAILURE', + 'LNCP-AAD-20240412-001', '{"status":"failure","error_code":"INVALID_AADHAAR","message":"Invalid Aadhaar number"}', + 'Aadhaar verification failed — invalid number provided', + 1, '2024-04-12 09:15:00+05:30', 1, '2024-04-12 09:15:00+05:30'), + + -- Suresh's PAN verification + (6, 3, 'PAN', 'SUCCESS', + 'LNCP-PAN-20240502-001', '{"status":"success","pan":"CDEPI9012C","name":"SURESH IYER","valid":true}', + 'PAN verified', + 1, '2024-05-02 16:00:00+05:30', 1, '2024-05-02 16:00:00+05:30'), + + -- Suresh's Aadhaar OTP + verify + (7, 3, 'AADHAAR', 'SUCCESS', + 'LNCP-AAD-20240503-001', '{"status":"success","aadhaar_last4":"1234","name":"Suresh Iyer","verified":true}', + 'Aadhaar verified via OTP', + 1, '2024-05-03 10:00:00+05:30', 1, '2024-05-03 10:00:00+05:30'), + + -- TechNova's PAN verification + (8, 5, 'PAN', 'SUCCESS', + 'LNCP-PAN-20240702-001', '{"status":"success","pan":"AABCT1234E","name":"TECHNOVA SOLUTIONS PVT LTD","valid":true}', + 'Company PAN verified', + 1, '2024-07-02 11:30:00+05:30', 1, '2024-07-02 11:30:00+05:30'), + + -- TechNova's GSTIN verification + (9, 5, 'GSTIN', 'SUCCESS', + 'LNCP-GST-20240703-001', '{"status":"success","gstin":"27AABCT1234E1Z5","trade_name":"TechNova Solutions","valid":true,"active":true}', + 'GSTIN verified via LincPay', + 1, '2024-07-03 12:00:00+05:30', 1, '2024-07-03 12:00:00+05:30') +ON CONFLICT (id) DO NOTHING; + +SELECT setval('m_client_kyc_verification_log_id_seq', GREATEST((SELECT MAX(id) FROM m_client_kyc_verification_log), 9)); + +COMMIT; + +-- ============================================================ +-- Summary of inserted data: +-- ============================================================ +-- m_office : 4 new (Mumbai, Delhi, Bangalore, Mumbai Sub) +-- m_staff : 5 staff (3 loan officers, 1 ops, 1 sub-branch) +-- m_client : 5 clients (4 individuals + 1 company) +-- m_group / m_group_level : 2 centers + 3 SHG groups +-- m_savings_product : 4 products (Regular, FD, JDYS, Current) +-- m_savings_account : 5 accounts +-- m_product_loan : 2 products (MSME, Personal) +-- m_loan : 4 loans (2 active, 1 approved, 1 entity) +-- m_client_kyc : 5 KYC records (3 verified, 1 in-progress, 1 pending) +-- m_client_kyc_verification_log : 9 API audit log entries +-- ============================================================