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) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-03-18 17:54:10 +05:30
co-authored by Claude Opus 4.6
parent 78f37257a7
commit ba5365d4af
102 changed files with 9757 additions and 18 deletions
+273
View File
@@ -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."
}
}
]
}
+276
View File
@@ -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": []
}
]
}
+375
View File
@@ -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)."
}
}
]
}
]
}
@@ -24,7 +24,8 @@ public enum ChargeAppliesTo {
LOAN(1, "chargeAppliesTo.loan"), // LOAN(1, "chargeAppliesTo.loan"), //
SAVINGS(2, "chargeAppliesTo.savings"), // SAVINGS(2, "chargeAppliesTo.savings"), //
CLIENT(3, "chargeAppliesTo.client"), // CLIENT(3, "chargeAppliesTo.client"), //
SHARES(4, "chargeAppliesTo.shares"); // SHARES(4, "chargeAppliesTo.shares"), //
CURRENT_ACCOUNT(5, "chargeAppliesTo.currentAccount"); //
private final Integer value; private final Integer value;
private final String code; private final String code;
@@ -59,6 +60,9 @@ public enum ChargeAppliesTo {
case 4: case 4:
chargeAppliesToType = SHARES; chargeAppliesToType = SHARES;
break; break;
case 5:
chargeAppliesToType = CURRENT_ACCOUNT;
break;
default: default:
chargeAppliesToType = INVALID; chargeAppliesToType = INVALID;
break; break;
@@ -84,8 +88,12 @@ public enum ChargeAppliesTo {
return this.value.equals(SHARES.getValue()); return this.value.equals(SHARES.getValue());
} }
public boolean isCurrentAccountCharge() {
return this.value.equals(CURRENT_ACCOUNT.getValue());
}
public static Object[] validValues() { public static Object[] validValues() {
return new Object[] { ChargeAppliesTo.LOAN.getValue(), ChargeAppliesTo.SAVINGS.getValue(), ChargeAppliesTo.CLIENT.getValue(), return new Object[] { ChargeAppliesTo.LOAN.getValue(), ChargeAppliesTo.SAVINGS.getValue(), ChargeAppliesTo.CLIENT.getValue(),
ChargeAppliesTo.SHARES.getValue() }; ChargeAppliesTo.SHARES.getValue(), ChargeAppliesTo.CURRENT_ACCOUNT.getValue() };
} }
} }
@@ -53,6 +53,10 @@ public enum ChargeCalculationType {
return new Integer[] { ChargeCalculationType.FLAT.getValue(), ChargeCalculationType.PERCENT_OF_AMOUNT.getValue() }; 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() { public static Object[] validValuesForShares() {
return new Integer[] { ChargeCalculationType.FLAT.getValue(), ChargeCalculationType.PERCENT_OF_AMOUNT.getValue() }; return new Integer[] { ChargeCalculationType.FLAT.getValue(), ChargeCalculationType.PERCENT_OF_AMOUNT.getValue() };
} }
@@ -255,6 +255,38 @@ public final class ChargeDefinitionCommandFromApiJsonDeserializer {
baseDataValidator.reset().parameter(CHARGE_CALCULATION_TYPE).value(chargeCalculationType) baseDataValidator.reset().parameter(CHARGE_CALCULATION_TYPE).value(chargeCalculationType)
.isOneOfTheseValues(ChargeCalculationType.validValuesForShareAccountActivation()); .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); final String name = this.fromApiJsonHelper.extractStringNamed(NAME, element);
@@ -47,4 +47,8 @@ public interface ChargeDropdownReadPlatformService {
List<EnumOptionData> retrieveSharesCollectionTimeTypes(); List<EnumOptionData> retrieveSharesCollectionTimeTypes();
List<EnumOptionData> retrieveCurrentAccountCalculationTypes();
List<EnumOptionData> retrieveCurrentAccountCollectionTimeTypes();
} }
@@ -124,4 +124,18 @@ public class ChargeDropdownReadPlatformServiceImpl implements ChargeDropdownRead
return Arrays.asList(chargeTimeType(ChargeTimeType.SHAREACCOUNT_ACTIVATION), chargeTimeType(ChargeTimeType.SHARE_PURCHASE), return Arrays.asList(chargeTimeType(ChargeTimeType.SHAREACCOUNT_ACTIVATION), chargeTimeType(ChargeTimeType.SHARE_PURCHASE),
chargeTimeType(ChargeTimeType.SHARE_REDEEM)); chargeTimeType(ChargeTimeType.SHARE_REDEEM));
} }
@Override
public List<EnumOptionData> retrieveCurrentAccountCalculationTypes() {
return Arrays.asList(chargeCalculationType(ChargeCalculationType.FLAT),
chargeCalculationType(ChargeCalculationType.PERCENT_OF_AMOUNT));
}
@Override
public List<EnumOptionData> 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));
}
} }
@@ -130,6 +130,10 @@ public final class ChargeEnumerations {
case SHARES: case SHARES:
optionData = new EnumOptionData(ChargeAppliesTo.SHARES.getValue().longValue(), ChargeAppliesTo.SHARES.getCode(), "Shares"); optionData = new EnumOptionData(ChargeAppliesTo.SHARES.getValue().longValue(), ChargeAppliesTo.SHARES.getCode(), "Shares");
break; break;
case CURRENT_ACCOUNT:
optionData = new EnumOptionData(ChargeAppliesTo.CURRENT_ACCOUNT.getValue().longValue(),
ChargeAppliesTo.CURRENT_ACCOUNT.getCode(), "Current Account");
break;
default: default:
optionData = new EnumOptionData(ChargeAppliesTo.INVALID.getValue().longValue(), ChargeAppliesTo.INVALID.getCode(), optionData = new EnumOptionData(ChargeAppliesTo.INVALID.getValue().longValue(), ChargeAppliesTo.INVALID.getCode(),
"Invalid"); "Invalid");
@@ -60,6 +60,7 @@ public enum JobName {
ACCRUAL_ACTIVITY_POSTING("Accrual Activity Posting"), // ACCRUAL_ACTIVITY_POSTING("Accrual Activity Posting"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_SAVINGS_WITH_INCOME_POSTED_AS_TRANSACTIONS("Add Accrual Transactions For Savings"), // ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_SAVINGS_WITH_INCOME_POSTED_AS_TRANSACTIONS("Add Accrual Transactions For Savings"), //
JOURNAL_ENTRY_AGGREGATION("Journal Entry Aggregation"), // JOURNAL_ENTRY_AGGREGATION("Journal Entry Aggregation"), //
FETCH_RBI_RSS_FEEDS("Fetch RBI RSS Feeds"), //
; // ; //
private final String name; private final String name;
@@ -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;
}
@@ -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<String, Integer> byStatus;
private Map<String, Integer> byType;
private Map<String, Integer> byPriority;
}
@@ -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;
}
@@ -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);
}
}
@@ -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<Long> {
@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;
}
@@ -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<RbiFeedItem, Long>, JpaSpecificationExecutor<RbiFeedItem> {
boolean existsByLink(String link);
List<RbiFeedItem> findByCategoryOrderByPubDateDesc(String category);
List<RbiFeedItem> findAllByOrderByPubDateDesc();
List<RbiFeedItem> findByCategoryAndOpenProjectWpIdIsNotNull(String category);
List<RbiFeedItem> findByCategoryAndOpenProjectWpIdIsNull(String category);
}
@@ -87,6 +87,8 @@ public final class ChargeData implements Comparable<ChargeData>, Serializable {
private final List<EnumOptionData> clientChargeTimeTypeOptions; private final List<EnumOptionData> clientChargeTimeTypeOptions;
private final List<EnumOptionData> shareChargeCalculationTypeOptions; private final List<EnumOptionData> shareChargeCalculationTypeOptions;
private final List<EnumOptionData> shareChargeTimeTypeOptions; private final List<EnumOptionData> shareChargeTimeTypeOptions;
private final List<EnumOptionData> currentAccountChargeCalculationTypeOptions;
private final List<EnumOptionData> currentAccountChargeTimeTypeOptions;
private final List<EnumOptionData> feeFrequencyOptions; private final List<EnumOptionData> feeFrequencyOptions;
@@ -113,6 +115,8 @@ public final class ChargeData implements Comparable<ChargeData>, Serializable {
.taxGroupOptions(template.getTaxGroupOptions()) .taxGroupOptions(template.getTaxGroupOptions())
.shareChargeCalculationTypeOptions(template.getShareChargeCalculationTypeOptions()) .shareChargeCalculationTypeOptions(template.getShareChargeCalculationTypeOptions())
.shareChargeTimeTypeOptions(template.getShareChargeTimeTypeOptions()) .shareChargeTimeTypeOptions(template.getShareChargeTimeTypeOptions())
.currentAccountChargeCalculationTypeOptions(template.getCurrentAccountChargeCalculationTypeOptions())
.currentAccountChargeTimeTypeOptions(template.getCurrentAccountChargeTimeTypeOptions())
.accountMappingForChargeConfig(template.getAccountMappingForChargeConfig()) .accountMappingForChargeConfig(template.getAccountMappingForChargeConfig())
.expenseAccountOptions(template.getExpenseAccountOptions()).assetAccountOptions(template.getAssetAccountOptions()).build(); .expenseAccountOptions(template.getExpenseAccountOptions()).assetAccountOptions(template.getAssetAccountOptions()).build();
} }
@@ -72,6 +72,13 @@ public enum ChargeTimeType {
ChargeTimeType.SAVINGS_NOACTIVITY_FEE.getValue() }; 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() { public static Object[] validClientValues() {
return new Integer[] { ChargeTimeType.SPECIFIED_DUE_DATE.getValue() }; return new Integer[] { ChargeTimeType.SPECIFIED_DUE_DATE.getValue() };
} }
@@ -181,6 +181,7 @@ public class ClientApiConstants {
public static final String datatables = "datatables"; public static final String datatables = "datatables";
public static final String obligeeData = "ObligeeDetails"; public static final String obligeeData = "ObligeeDetails";
public static final String kycParamName = "kyc";
public static final String clientEntityName = "clients"; public static final String clientEntityName = "clients";
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.client.data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
@@ -53,4 +54,7 @@ public class ClientFamilyMemberRequest implements Serializable {
private Long id; private Long id;
private Long age; private Long age;
private Long professionId; private Long professionId;
private String nominationType;
private BigDecimal percentageShare;
private String remarks;
} }
@@ -20,6 +20,7 @@
package org.apache.fineract.portfolio.client.data; package org.apache.fineract.portfolio.client.data;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection; import java.util.Collection;
import lombok.Builder; import lombok.Builder;
@@ -66,6 +67,12 @@ public final class ClientFamilyMembersData implements Serializable {
private final Boolean isDependent; private final Boolean isDependent;
private final String nominationType;
private final BigDecimal percentageShare;
private final String remarks;
// template holder // template holder
private final Collection<CodeValueData> relationshipIdOptions; private final Collection<CodeValueData> relationshipIdOptions;
private final Collection<CodeValueData> genderIdOptions; private final Collection<CodeValueData> genderIdOptions;
@@ -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).
*
* <p>One record per client. Contains:
* <ul>
* <li>PAN verification (Permanent Account Number – Income Tax Dept)</li>
* <li>Aadhaar verification (12-digit UIDAI number, stored masked)</li>
* <li>GSTIN verification (for non-individual/business clients)</li>
* <li>Risk category, CKYCRR number, expiry &amp; lifecycle timestamps</li>
* </ul>
*/
@Entity
@Table(name = "m_client_kyc", uniqueConstraints = {
@UniqueConstraint(columnNames = { "client_id" }, name = "uq_m_client_kyc_client_id") })
public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@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<String, Object> update(final JsonCommand command) {
final Map<String, Object> 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;
}
}
@@ -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<ClientKyc, Long> {
Optional<ClientKyc> findByClientId(Long clientId);
@Query("SELECT k FROM ClientKyc k WHERE k.client.id = :clientId")
Optional<ClientKyc> findKycByClientId(@Param("clientId") Long clientId);
boolean existsByClientId(Long clientId);
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
}
@@ -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);
}
}
@@ -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).
*
* <pre>
* 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)
* </pre>
*/
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;
}
}
@@ -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;
}
}
@@ -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.
*
* <ul>
* <li>LOW – re-KYC every 10 years</li>
* <li>MEDIUM – re-KYC every 8 years</li>
* <li>HIGH – re-KYC every 2 years</li>
* </ul>
*/
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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<Long> {
@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();
}
}
@@ -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<MobileLookupLog, Long> {
@Query("SELECT l FROM MobileLookupLog l ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findAllOrderByLookedUpOnDesc();
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findByMobileNumber(@Param("mobileNumber") String mobileNumber);
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
}
@@ -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<Long> {
@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; }
}
@@ -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<Long> {
@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<SanctionsAlias> 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<SanctionsAlias> getAliases() { return aliases; }
}
@@ -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<SanctionsEntry, Long> {
@Query("SELECT s FROM SanctionsEntry s WHERE s.active = true AND LOWER(s.fullName) LIKE LOWER(CONCAT('%', :name, '%'))")
List<SanctionsEntry> 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<SanctionsEntry> searchByNameOrAlias(@Param("name") String name);
boolean existsByDataId(String dataId);
void deleteAllByActiveTrue();
long countByActiveTrue();
}
@@ -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<Long> {
@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; }
}
@@ -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<SanctionsScreeningLog, Long> {
@Query("SELECT l FROM SanctionsScreeningLog l ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findAllOrderByScreenedOnDesc();
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.screeningResult = :result ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> 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<SanctionsScreeningLog> 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<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
}
@@ -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<String> PROJECT_RESPONSE_PARAMS = new HashSet<>(
Arrays.asList("id", "identifier", "name", "active", "description", "createdAt", "updatedAt"));
private static final Set<String> WORK_PACKAGE_RESPONSE_PARAMS = new HashSet<>(
Arrays.asList("id", "subject", "description", "typeName", "statusName", "priorityName",
"assigneeName", "percentageDone", "startDate", "dueDate", "createdAt", "updatedAt"));
private static final Set<String> 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<OpenProjectProjectData> projectSerializer;
private final DefaultToApiJsonSerializer<OpenProjectWorkPackageData> workPackageSerializer;
private final DefaultToApiJsonSerializer<OpenProjectStatusSummaryData> 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<OpenProjectWorkPackageData> 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);
}
}
@@ -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;
}
@@ -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<OpenProjectWorkPackageData> 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<OpenProjectWorkPackageData> 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<OpenProjectWorkPackageData> all = getAllWorkPackages();
Map<String, Integer> byStatus = new LinkedHashMap<>();
Map<String, Integer> byType = new LinkedHashMap<>();
Map<String, Integer> 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<OpenProjectWorkPackageData> parseWorkPackages(JsonObject json) {
Collection<OpenProjectWorkPackageData> 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;
}
}
@@ -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<String> 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<RbiFeedItemData> 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<RbiFeedItemData> 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));
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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<String, String> 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<String, String> 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<RbiFeedItem> 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<InputStream> 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<RbiFeedItem> 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<RbiFeedItem> 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<RbiFeedItem> 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;
}
}
@@ -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<RbiFeedItemData> retrieveAll();
Collection<RbiFeedItemData> retrieveByCategory(String category);
RbiFeedItemData retrieveOne(Long feedItemId);
}
@@ -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<RbiFeedItemData> {
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<RbiFeedItemData> 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<RbiFeedItemData> results = this.jdbcTemplate.query(sql, rm);
log.debug("RBI Feed ReadService - retrieveAll returned {} items", results.size());
return results;
}
@Override
public Collection<RbiFeedItemData> 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<RbiFeedItemData> 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);
}
}
@@ -135,6 +135,10 @@ public class ChargeReadPlatformServiceImpl implements ChargeReadPlatformService
final List<EnumOptionData> shareChargeCalculationTypeOptions = this.chargeDropdownReadPlatformService final List<EnumOptionData> shareChargeCalculationTypeOptions = this.chargeDropdownReadPlatformService
.retrieveSharesCalculationTypes(); .retrieveSharesCalculationTypes();
final List<EnumOptionData> shareChargeTimeTypeOptions = this.chargeDropdownReadPlatformService.retrieveSharesCollectionTimeTypes(); final List<EnumOptionData> shareChargeTimeTypeOptions = this.chargeDropdownReadPlatformService.retrieveSharesCollectionTimeTypes();
final List<EnumOptionData> currentAccountChargeCalculationTypeOptions = this.chargeDropdownReadPlatformService
.retrieveCurrentAccountCalculationTypes();
final List<EnumOptionData> currentAccountChargeTimeTypeOptions = this.chargeDropdownReadPlatformService
.retrieveCurrentAccountCollectionTimeTypes();
final Collection<TaxGroupData> taxGroupOptions = this.taxReadPlatformService.retrieveTaxGroupsForLookUp(); final Collection<TaxGroupData> taxGroupOptions = this.taxReadPlatformService.retrieveTaxGroupsForLookUp();
final String accountMappingForChargeConfig = this.configurationDomainServiceJpa.getAccountMappingForCharge(); final String accountMappingForChargeConfig = this.configurationDomainServiceJpa.getAccountMappingForCharge();
final List<GLAccountData> expenseAccountOptions = this.accountingDropdownReadPlatformService.retrieveExpenseAccountOptions(); final List<GLAccountData> expenseAccountOptions = this.accountingDropdownReadPlatformService.retrieveExpenseAccountOptions();
@@ -150,6 +154,8 @@ public class ChargeReadPlatformServiceImpl implements ChargeReadPlatformService
.clientChargeTimeTypeOptions(clientChargeTimeTypeOptions).feeFrequencyOptions(feeFrequencyOptions) .clientChargeTimeTypeOptions(clientChargeTimeTypeOptions).feeFrequencyOptions(feeFrequencyOptions)
.incomeOrLiabilityAccountOptions(incomeOrLiabilityAccountOptions).taxGroupOptions(taxGroupOptions) .incomeOrLiabilityAccountOptions(incomeOrLiabilityAccountOptions).taxGroupOptions(taxGroupOptions)
.shareChargeCalculationTypeOptions(shareChargeCalculationTypeOptions).shareChargeTimeTypeOptions(shareChargeTimeTypeOptions) .shareChargeCalculationTypeOptions(shareChargeCalculationTypeOptions).shareChargeTimeTypeOptions(shareChargeTimeTypeOptions)
.currentAccountChargeCalculationTypeOptions(currentAccountChargeCalculationTypeOptions)
.currentAccountChargeTimeTypeOptions(currentAccountChargeTimeTypeOptions)
.accountMappingForChargeConfig(accountMappingForChargeConfig).expenseAccountOptions(expenseAccountOptions) .accountMappingForChargeConfig(accountMappingForChargeConfig).expenseAccountOptions(expenseAccountOptions)
.assetAccountOptions(assetAccountOptions).build(); .assetAccountOptions(assetAccountOptions).build();
} }
@@ -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.
*
* <pre>
* 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
* </pre>
*/
@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<ClientKycData> 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;
}
}
}
@@ -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<ApiParameterError> 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<ApiParameterError> 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<ApiParameterError> 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<ApiParameterError> 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<ApiParameterError> 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<ApiParameterError> 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<ApiParameterError> 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; }
}
@@ -30,7 +30,7 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
emailAddressParamName, firstnameParamName, middlenameParamName, lastnameParamName, fullnameParamName, officeIdParamName, emailAddressParamName, firstnameParamName, middlenameParamName, lastnameParamName, fullnameParamName, officeIdParamName,
activeParamName, activationDateParamName, staffIdParamName, submittedOnDateParamName, savingsProductIdParamName, activeParamName, activationDateParamName, staffIdParamName, submittedOnDateParamName, savingsProductIdParamName,
dateOfBirthParamName, genderIdParamName, clientTypeIdParamName, clientClassificationIdParamName, dateOfBirthParamName, genderIdParamName, clientTypeIdParamName, clientClassificationIdParamName,
clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName)); clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, datatables, isStaffParamName, kycParamName));
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>( protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
@@ -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<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam,
gstinNumberParam, remarksParam, "locale"));
public static final Set<String> 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<String> 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<String> 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<String> 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<String> 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<String> VERIFY_GSTIN_REQUEST_PARAMS = new HashSet<>();
public static final Set<String> 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}$";
}
@@ -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;
}
@@ -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;
}
@@ -24,6 +24,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -76,9 +77,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
@Column(name = "date_of_birth", nullable = true) @Column(name = "date_of_birth", nullable = true)
private LocalDate dateOfBirth; 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, 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 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.client = client;
this.firstName = firstName; this.firstName = firstName;
@@ -93,6 +104,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
this.gender = gender; this.gender = gender;
this.dateOfBirth = dateOfBirth; this.dateOfBirth = dateOfBirth;
this.profession = profession; this.profession = profession;
this.nominationType = nominationType;
this.percentageShare = percentageShare;
this.remarks = remarks;
} }
public ClientFamilyMembers() { public ClientFamilyMembers() {
@@ -101,9 +115,10 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
public static ClientFamilyMembers fromJson(final Client client, final String firstName, final String middleName, final String lastName, 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 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, 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() { public Client getClient() {
@@ -210,4 +225,28 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
this.isDependent = isDependent; 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;
}
} }
@@ -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.
*
* <p>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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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).
*
* <p>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);
}
}
@@ -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);
}
}
@@ -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.
*
* <p>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);
}
}
@@ -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.
*
* <p>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);
}
}
@@ -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;
}
}
@@ -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.
*
* <p>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}.
*
* <p>Aadhaar verification is a two-step OTP flow:
* <ol>
* <li>Call {@link #generateAadhaarOtp(String)} with the client's full Aadhaar – returns a
* {@code refNumber} that must be passed back to the client-facing app.</li>
* <li>Call {@link #verifyAadhaar(String, String)} with the {@code refNumber} and the OTP
* that the client received on their registered mobile.</li>
* </ol>
*/
public interface KycVerificationService {
/**
* Step 1 of Aadhaar e-KYC: generate an OTP to the client's UIDAI-registered mobile.
*
* <p><strong>IMPORTANT:</strong> 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);
}
@@ -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}.
*
* <p><strong>NOT active in production.</strong> 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.
*
* <p>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 + "\"}");
}
}
@@ -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
* <a href="https://ekyc.lincpay.in/EKYC/">LincPay eKYC API</a>.
*
* <p>Configure the following properties in {@code application.properties}:
* <pre>
* 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
* </pre>
*
* <p><b>Aadhaar OTP flow (two-step):</b>
* <ol>
* <li>{@link #generateAadhaarOtp(String)} – takes full Aadhaar, triggers OTP to
* client's registered mobile, returns a {@code refNumber}.</li>
* <li>{@link #verifyAadhaar(String, String)} – takes the {@code refNumber} and the OTP
* entered by the client; returns success/failure.</li>
* </ol>
*
* <p><b>PAN:</b> Single-step – {@link #verifyPan(String, String, String)} validates PAN,
* name, and optional DOB against the Income Tax Department records.
*
* <p><b>GSTIN:</b> 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<String> entity = new HttpEntity<>(requestBodyJson, headers);
final ResponseEntity<String> 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";
}
}
@@ -59,9 +59,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
public static final String LOCALE = "locale"; public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat"; public static final String DATE_FORMAT = "dateFormat";
public static final String FAMILY_MEMBERS = "familyMembers"; 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<String> SUPPORTED_PARAMETERS = new HashSet<>( private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT, 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 FAMILY_MEMBERS1 = "FamilyMembers";
public static final String RELATION_SHIP_ID = "relationShipId"; public static final String RELATION_SHIP_ID = "relationShipId";
private final FromJsonHelper fromApiJsonHelper; 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) { 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) { public void validateForDelete(final long familyMemberId) {
@@ -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<String> supportedParams) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.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);
}
}
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.client.service; package org.apache.fineract.portfolio.client.service;
import java.math.BigDecimal;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.LocalDate; 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," 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," + "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," + "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" + " 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 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"; + " 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 LocalDate dateOfBirth = JdbcSupport.getLocalDate(rs, "dateOfBirth");
final String profession = rs.getString("profession"); final String profession = rs.getString("profession");
final long professionId = rs.getLong("professionId"); 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) return ClientFamilyMembersData.builder().id(id).clientId(clientId).firstName(firstName).middleName(middleName)
.lastName(lastName).qualification(qualification).mobileNumber(mobileNumber).age(age).isDependent(isDependent) .lastName(lastName).qualification(qualification).mobileNumber(mobileNumber).age(age).isDependent(isDependent)
.relationship(relationship).relationshipId(relationshipId).maritalStatus(maritalStatus).maritalStatusId(maritalStatusId) .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();
} }
} }
@@ -22,6 +22,7 @@ package org.apache.fineract.portfolio.client.service;
import com.google.gson.JsonArray; import com.google.gson.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeFormatterBuilder;
@@ -83,6 +84,9 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
Long age = null; Long age = null;
Boolean isDependent = false; Boolean isDependent = false;
LocalDate dateOfBirth = null; LocalDate dateOfBirth = null;
String nominationType = null;
BigDecimal percentageShare = null;
String remarks = null;
this.context.authenticatedUser(); this.context.authenticatedUser();
apiJsonDeserializer.validateForCreate(clientId, command.json()); apiJsonDeserializer.validateForCreate(clientId, command.json());
@@ -95,6 +99,9 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
mobileNumber = command.stringValueOfParameterNamed("mobileNumber"); mobileNumber = command.stringValueOfParameterNamed("mobileNumber");
age = command.longValueOfParameterNamed("age"); age = command.longValueOfParameterNamed("age");
isDependent = command.booleanObjectValueOfParameterNamed("isDependent"); isDependent = command.booleanObjectValueOfParameterNamed("isDependent");
nominationType = command.stringValueOfParameterNamed("nominationType");
percentageShare = command.bigDecimalValueOfParameterNamed("percentageShare");
remarks = command.stringValueOfParameterNamed("remarks");
if (command.longValueOfParameterNamed("relationshipId") != null) { if (command.longValueOfParameterNamed("relationshipId") != null) {
relationshipId = command.longValueOfParameterNamed("relationshipId"); relationshipId = command.longValueOfParameterNamed("relationshipId");
@@ -119,7 +126,8 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
dateOfBirth = command.localDateValueOfParameterNamed("dateOfBirth"); dateOfBirth = command.localDateValueOfParameterNamed("dateOfBirth");
ClientFamilyMembers clientFamilyMembers = ClientFamilyMembers.fromJson(client, firstName, middleName, lastName, qualification, 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); this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
@@ -146,6 +154,9 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
String mobileNumber = ""; String mobileNumber = "";
Long age = null; Long age = null;
Boolean isDependent = false; Boolean isDependent = false;
String nominationType = null;
BigDecimal percentageShare = null;
String remarks = null;
this.context.authenticatedUser(); 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, 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); 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=ClientFamilyMembers.fromJson(client, firstName, // clientFamilyMembers=ClientFamilyMembers.fromJson(client, firstName,
// middleName, lastName, qualification, relationship, maritalStatus, // middleName, lastName, qualification, relationship, maritalStatus,
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
@@ -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<String, Object> 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<String, Object> 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.");
}
}
@@ -124,6 +124,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final BusinessEventNotifierService businessEventNotifierService; private final BusinessEventNotifierService businessEventNotifierService;
private final EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService; private final EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService;
private final ExternalIdFactory externalIdFactory; private final ExternalIdFactory externalIdFactory;
private final ClientKycWritePlatformService clientKycWritePlatformService;
@Transactional @Transactional
@Override @Override
@@ -329,6 +330,16 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
newClient.getId(), null, command.arrayOfParameterNamed(ClientApiConstants.datatables)); 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()); legalForm = LegalForm.fromInt(newClient.getLegalForm());
entityDatatableChecksWritePlatformService.runTheCheck(newClient.getId(), EntityTables.CLIENT.getName(), entityDatatableChecksWritePlatformService.runTheCheck(newClient.getId(), EntityTables.CLIENT.getName(),
StatusEnum.CREATE.getValue(), EntityTables.CLIENT.getForeignKeyColumnNameOnDatatable(), legalForm.getLabel()); StatusEnum.CREATE.getValue(), EntityTables.CLIENT.getForeignKeyColumnNameOnDatatable(), legalForm.getLabel());
@@ -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.
*
* <pre>
* GET /v1/lookup/check?mobileNumber={mobileNumber} – Check mobile number
* GET /v1/lookup/logs – Get all lookup logs
* GET /v1/lookup/logs?mobileNumber={num}&amp;status={status} – Filter logs
* </pre>
*/
@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<MobileLookupResult> resultSerializer;
private final DefaultToApiJsonSerializer<List> 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<MobileLookupLog> 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<MobileLookupLogData> logDataList = logs.stream()
.map(MobileLookupLogData::fromEntity)
.collect(Collectors.toList());
return listSerializer.serialize(logDataList);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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<MobileLookupLog> getLookupLogs();
List<MobileLookupLog> getLookupLogsByMobileNumber(String mobileNumber);
List<MobileLookupLog> getLookupLogsByStatus(String status);
}
@@ -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<String> 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<String> 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<MobileLookupLog> getLookupLogs() {
return logRepository.findAllOrderByLookedUpOnDesc();
}
@Override
@Transactional(readOnly = true)
public List<MobileLookupLog> getLookupLogsByMobileNumber(final String mobileNumber) {
return logRepository.findByMobileNumber(mobileNumber);
}
@Override
@Transactional(readOnly = true)
public List<MobileLookupLog> 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;
}
}
@@ -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.
*
* <pre>
* 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
* </pre>
*/
@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<SanctionsScreeningResult> screeningResultSerializer;
private final DefaultToApiJsonSerializer<Map> mapSerializer;
private final DefaultToApiJsonSerializer<List> 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<String, Object> 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<String, Object> 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<SanctionsScreeningLog> 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<SanctionsScreeningLogData> logDataList = logs.stream()
.map(SanctionsScreeningLogData::fromEntity)
.collect(Collectors.toList());
return listSerializer.serialize(logDataList);
}
}
@@ -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<String> 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<String> 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<String> getAliases() { return aliases; }
}
@@ -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; }
}
@@ -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<SanctionsMatchData> matches;
private SanctionsScreeningResult(final String screenedName, final boolean matchFound,
final String status, final String message, final int matchCount,
final List<SanctionsMatchData> 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<SanctionsMatchData> 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<SanctionsMatchData> getMatches() { return matches; }
}
@@ -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<SanctionsScreeningLog> getScreeningLogs();
/**
* Get screening logs filtered by result (FLAGGED or CLEAR).
*/
List<SanctionsScreeningLog> getScreeningLogsByResult(String result);
/**
* Search screening logs by screened name.
*/
List<SanctionsScreeningLog> searchScreeningLogs(String name);
}
@@ -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<SanctionsEntry> 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<SanctionsMatchData> 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<SanctionsScreeningLog> getScreeningLogs() {
return screeningLogRepository.findAllOrderByScreenedOnDesc();
}
@Override
@Transactional(readOnly = true)
public List<SanctionsScreeningLog> getScreeningLogsByResult(final String result) {
return screeningLogRepository.findByScreeningResult(result);
}
@Override
@Transactional(readOnly = true)
public List<SanctionsScreeningLog> 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<String> 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
);
}
}
@@ -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<SanctionsEntry> 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<String> 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;
}
}
@@ -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.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.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:3306} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:root} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:mysql} fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:root}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} 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.max=${FINERACT_SERVER_TOMCAT_THREADS_MAX:200}
server.tomcat.threads.min-spare=${FINERACT_SERVER_TOMCAT_THREADS_MIN_SPARE:10} server.tomcat.threads.min-spare=${FINERACT_SERVER_TOMCAT_THREADS_MIN_SPARE:10}
server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABLED:false} 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.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:mariadb://localhost:3306/fineract_tenants} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:root} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:mysql} spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:root}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} 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.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.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} 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}
@@ -0,0 +1,336 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
KYC module – compliant with RBI Master Direction on KYC (updated 2023).
Tables created:
m_client_kyc – one KYC record per client
m_client_kyc_verification_log – 3rd-party API call audit trail
Permissions inserted:
READ_CLIENTKYC, CREATE_CLIENTKYC, UPDATE_CLIENTKYC,
VERIFY_CLIENTKYC, REJECT_CLIENTKYC
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_client_kyc
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="1" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_kyc"/></not>
</preConditions>
<createTable tableName="m_client_kyc">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_m_client_kyc_client_id"/>
</column>
<!-- kyc_status: 100=PENDING 200=IN_PROGRESS 300=VERIFIED 400=REJECTED 500=EXPIRED -->
<!-- INTEGER used (not SMALLINT) to match Java Integer JPA default mapping -->
<column name="kyc_status" type="INTEGER" defaultValueNumeric="100">
<constraints nullable="false"/>
</column>
<!-- kyc_type: 1=INDIVIDUAL 2=NON_INDIVIDUAL -->
<column name="kyc_type" type="INTEGER">
<constraints nullable="false"/>
</column>
<!-- risk_category: 1=LOW(10yr) 2=MEDIUM(8yr) 3=HIGH(2yr) -->
<column name="risk_category" type="INTEGER" defaultValueNumeric="1">
<constraints nullable="false"/>
</column>
<!-- PAN (Permanent Account Number – Income Tax Dept) -->
<column name="pan_number" type="VARCHAR(10)"/>
<column name="pan_verified" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="pan_verified_on" type="DATE"/>
<!-- Aadhaar – stored masked per UIDAI directive (only last 4 digits) -->
<column name="aadhaar_number_masked" type="VARCHAR(12)"/>
<column name="aadhaar_verified" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="aadhaar_verified_on" type="DATE"/>
<!-- GSTIN – mandatory for NON_INDIVIDUAL clients -->
<column name="gstin_number" type="VARCHAR(15)"/>
<column name="gstin_verified" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="gstin_verified_on" type="DATE"/>
<!-- CKYCRR – Central KYC Records Registry number (CERSAI) -->
<column name="ckyc_number" type="VARCHAR(14)"/>
<!-- Lifecycle timestamps -->
<column name="initiated_on" type="DATE"/>
<column name="verified_on" type="DATE"/>
<column name="rejected_on" type="DATE"/>
<!-- expiry_date: derived from risk_category * verified_on year offset -->
<column name="expiry_date" type="DATE"/>
<column name="remarks" type="VARCHAR(500)"/>
<!-- Audit fields (AbstractAuditableWithUTCDateTimeCustom) -->
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
PostgreSQL – m_client_kyc
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="1" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_kyc"/></not>
</preConditions>
<createTable tableName="m_client_kyc">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_m_client_kyc_client_id"/>
</column>
<!-- INTEGER used (not SMALLINT) to match Java Integer JPA default mapping -->
<column name="kyc_status" type="INTEGER" defaultValueNumeric="100">
<constraints nullable="false"/>
</column>
<column name="kyc_type" type="INTEGER">
<constraints nullable="false"/>
</column>
<column name="risk_category" type="INTEGER" defaultValueNumeric="1">
<constraints nullable="false"/>
</column>
<column name="pan_number" type="VARCHAR(10)"/>
<column name="pan_verified" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="pan_verified_on" type="DATE"/>
<column name="aadhaar_number_masked" type="VARCHAR(12)"/>
<column name="aadhaar_verified" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="aadhaar_verified_on" type="DATE"/>
<column name="gstin_number" type="VARCHAR(15)"/>
<column name="gstin_verified" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="gstin_verified_on" type="DATE"/>
<column name="ckyc_number" type="VARCHAR(14)"/>
<column name="initiated_on" type="DATE"/>
<column name="verified_on" type="DATE"/>
<column name="rejected_on" type="DATE"/>
<column name="expiry_date" type="DATE"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
FK: m_client_kyc.client_id → m_client.id
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="2">
<preConditions onFail="MARK_RAN">
<not>
<foreignKeyConstraintExists foreignKeyTableName="m_client_kyc"
foreignKeyName="FK_m_client_kyc_client_id"/>
</not>
</preConditions>
<addForeignKeyConstraint
baseTableName="m_client_kyc"
baseColumnNames="client_id"
constraintName="FK_m_client_kyc_client_id"
referencedTableName="m_client"
referencedColumnNames="id"
onDelete="RESTRICT" onUpdate="RESTRICT"
deferrable="false" initiallyDeferred="false" validate="true"/>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_client_kyc_verification_log (3rd-party API audit trail)
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="3" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_kyc_verification_log"/></not>
</preConditions>
<createTable tableName="m_client_kyc_verification_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="kyc_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- verification_type: PAN | AADHAAR | GSTIN -->
<column name="verification_type" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<!-- Status: SUCCESS | FAILURE | PENDING | ERROR -->
<column name="verification_status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<!-- External reference id returned by the 3rd-party API -->
<column name="request_reference" type="VARCHAR(100)"/>
<!-- Raw API response stored for audit -->
<column name="api_response" type="TEXT"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- PostgreSQL – m_client_kyc_verification_log -->
<changeSet author="fineract" id="3" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_kyc_verification_log"/></not>
</preConditions>
<createTable tableName="m_client_kyc_verification_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="kyc_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="verification_type" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="verification_status" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="request_reference" type="VARCHAR(100)"/>
<column name="api_response" type="TEXT"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- FK: m_client_kyc_verification_log.kyc_id → m_client_kyc.id -->
<changeSet author="fineract" id="4">
<preConditions onFail="MARK_RAN">
<not>
<foreignKeyConstraintExists foreignKeyTableName="m_client_kyc_verification_log"
foreignKeyName="FK_m_kyc_verification_log_kyc_id"/>
</not>
</preConditions>
<addForeignKeyConstraint
baseTableName="m_client_kyc_verification_log"
baseColumnNames="kyc_id"
constraintName="FK_m_kyc_verification_log_kyc_id"
referencedTableName="m_client_kyc"
referencedColumnNames="id"
onDelete="CASCADE" onUpdate="RESTRICT"
deferrable="false" initiallyDeferred="false" validate="true"/>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
Permissions – inserted into m_permission
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="5">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code = 'READ_CLIENTKYC'
</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="UPDATE_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="UPDATE"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="VERIFY_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="VERIFY"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="REJECT_CLIENTKYC"/>
<column name="entity_name" value="CLIENTKYC"/>
<column name="action_name" value="REJECT"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<changeSet author="fineract" id="0217-1">
<addColumn tableName="m_family_members">
<column name="nomination_type" type="VARCHAR(50)" defaultValue="NULL">
<constraints nullable="true"/>
</column>
<column name="percentage_share" type="DECIMAL(5,2)" defaultValue="NULL">
<constraints nullable="true"/>
</column>
<column name="remarks" type="VARCHAR(500)" defaultValue="NULL">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet author="fineract" id="0218-1" context="mysql">
<addColumn tableName="m_client_kyc">
<column name="pan_full_name" type="VARCHAR(200)" />
<column name="pan_aadhaar_seeding_status" type="VARCHAR(5)" remarks="Y=YES, N=NO, NA=Not Available" />
<column name="pan_category" type="VARCHAR(50)" remarks="PAN card category e.g. individual, company, firm" />
<column name="pan_status" type="VARCHAR(20)" remarks="valid or invalid" />
</addColumn>
</changeSet>
<changeSet author="fineract" id="0218-1" context="postgresql">
<addColumn tableName="m_client_kyc">
<column name="pan_full_name" type="VARCHAR(200)" />
<column name="pan_aadhaar_seeding_status" type="VARCHAR(5)" remarks="Y=YES, N=NO, NA=Not Available" />
<column name="pan_category" type="VARCHAR(50)" remarks="PAN card category e.g. individual, company, firm" />
<column name="pan_status" type="VARCHAR(20)" remarks="valid or invalid" />
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="rbi_feed-1">
<createTable tableName="m_rbi_feed_item">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="title" type="VARCHAR(1000)">
<constraints nullable="false"/>
</column>
<column name="description" type="TEXT"/>
<column name="link" type="VARCHAR(1000)">
<constraints nullable="false"/>
</column>
<column name="pub_date" type="DATETIME"/>
<column name="category" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="fetched_on" type="DATETIME"/>
</createTable>
<addUniqueConstraint tableName="m_rbi_feed_item" columnNames="link" constraintName="uk_rbi_feed_item_link"/>
</changeSet>
<changeSet author="fineract" id="rbi_feed-2">
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="READ_RBI_FEED"/>
<column name="entity_name" value="RBI_FEED"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="organisation"/>
<column name="code" value="FETCH_RBI_FEED"/>
<column name="entity_name" value="RBI_FEED"/>
<column name="action_name" value="FETCH"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="rbi_feed-3">
<validCheckSum>ANY</validCheckSum>
<insert tableName="job">
<column name="name" value="Fetch RBI RSS Feeds"/>
<column name="display_name" value="Fetch RBI RSS Feeds"/>
<column name="short_name" value="RBIRSS"/>
<column name="cron_expression" value="0 0 */6 * * ?"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Fetch RBI RSS FeedsDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="0"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0220-1" context="default">
<comment>Add OpenProject read permission</comment>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="READ_OPENPROJECT"/>
<column name="entity_name" value="OPENPROJECT"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0220-2" context="default">
<comment>Add OpenProject create/write permission</comment>
<insert tableName="m_permission">
<column name="grouping" value="infrastructure"/>
<column name="code" value="CREATE_OPENPROJECT"/>
<column name="entity_name" value="OPENPROJECT"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet author="fineract" id="0221-1" context="default">
<comment>Add OpenProject work package ID and status columns to RBI feed items for tracking</comment>
<addColumn tableName="m_rbi_feed_item">
<column name="openproject_wp_id" type="BIGINT" defaultValue="null">
<constraints nullable="true"/>
</column>
<column name="openproject_wp_status" type="VARCHAR(100)" defaultValue="null">
<constraints nullable="true"/>
</column>
<column name="openproject_wp_progress" type="INT" defaultValue="null">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,276 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
Sanctions screening module – stores UN Security Council consolidated sanctions list.
Tables created:
m_sanctions_entry – individuals and entities from the UN consolidated list
m_sanctions_alias – aliases (aka) for each sanctions entry
Permissions inserted:
READ_SANCTIONS, SCREEN_SANCTIONS, LOAD_SANCTIONS
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_sanctions_entry
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="1" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_sanctions_entry"/></not>
</preConditions>
<createTable tableName="m_sanctions_entry">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="data_id" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<!-- entry_type: INDIVIDUAL or ENTITY -->
<column name="entry_type" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="first_name" type="VARCHAR(255)"/>
<column name="second_name" type="VARCHAR(255)"/>
<column name="third_name" type="VARCHAR(255)"/>
<column name="fourth_name" type="VARCHAR(255)"/>
<!-- full_name: concatenation of all name parts for easy searching -->
<column name="full_name" type="VARCHAR(500)"/>
<column name="un_list_type" type="VARCHAR(255)"/>
<column name="reference_number" type="VARCHAR(255)"/>
<column name="listed_on" type="DATE"/>
<column name="gender" type="VARCHAR(20)"/>
<column name="nationality" type="VARCHAR(500)"/>
<column name="comments" type="TEXT"/>
<column name="designation" type="TEXT"/>
<column name="date_of_birth" type="VARCHAR(500)"/>
<column name="place_of_birth" type="VARCHAR(500)"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<!-- Audit fields -->
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
PostgreSQL – m_sanctions_entry
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="1" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_sanctions_entry"/></not>
</preConditions>
<createTable tableName="m_sanctions_entry">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="data_id" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="entry_type" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="first_name" type="VARCHAR(255)"/>
<column name="second_name" type="VARCHAR(255)"/>
<column name="third_name" type="VARCHAR(255)"/>
<column name="fourth_name" type="VARCHAR(255)"/>
<column name="full_name" type="VARCHAR(500)"/>
<column name="un_list_type" type="VARCHAR(255)"/>
<column name="reference_number" type="VARCHAR(255)"/>
<column name="listed_on" type="DATE"/>
<column name="gender" type="VARCHAR(20)"/>
<column name="nationality" type="VARCHAR(500)"/>
<column name="comments" type="TEXT"/>
<column name="designation" type="TEXT"/>
<column name="date_of_birth" type="VARCHAR(500)"/>
<column name="place_of_birth" type="VARCHAR(500)"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_sanctions_alias (aka names for sanctions entries)
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="2" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_sanctions_alias"/></not>
</preConditions>
<createTable tableName="m_sanctions_alias">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="sanctions_entry_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="alias_quality" type="VARCHAR(50)"/>
<column name="alias_name" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- PostgreSQL – m_sanctions_alias -->
<changeSet author="fineract" id="2" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_sanctions_alias"/></not>
</preConditions>
<createTable tableName="m_sanctions_alias">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="sanctions_entry_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="alias_quality" type="VARCHAR(50)"/>
<column name="alias_name" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- FK: m_sanctions_alias.sanctions_entry_id → m_sanctions_entry.id -->
<changeSet author="fineract" id="3">
<preConditions onFail="MARK_RAN">
<not>
<foreignKeyConstraintExists foreignKeyTableName="m_sanctions_alias"
foreignKeyName="FK_m_sanctions_alias_entry_id"/>
</not>
</preConditions>
<addForeignKeyConstraint
baseTableName="m_sanctions_alias"
baseColumnNames="sanctions_entry_id"
constraintName="FK_m_sanctions_alias_entry_id"
referencedTableName="m_sanctions_entry"
referencedColumnNames="id"
onDelete="CASCADE" onUpdate="RESTRICT"
deferrable="false" initiallyDeferred="false" validate="true"/>
</changeSet>
<!-- Index on full_name for faster name searches -->
<changeSet author="fineract" id="4">
<preConditions onFail="MARK_RAN">
<not>
<indexExists indexName="idx_sanctions_entry_full_name"/>
</not>
</preConditions>
<createIndex tableName="m_sanctions_entry" indexName="idx_sanctions_entry_full_name">
<column name="full_name"/>
</createIndex>
</changeSet>
<!-- Index on alias_name for faster alias searches -->
<changeSet author="fineract" id="5">
<preConditions onFail="MARK_RAN">
<not>
<indexExists indexName="idx_sanctions_alias_name"/>
</not>
</preConditions>
<createIndex tableName="m_sanctions_alias" indexName="idx_sanctions_alias_name">
<column name="alias_name"/>
</createIndex>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
Permissions
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="6">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code = 'READ_SANCTIONS'
</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_SANCTIONS"/>
<column name="entity_name" value="SANCTIONS"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="SCREEN_SANCTIONS"/>
<column name="entity_name" value="SANCTIONS"/>
<column name="action_name" value="SCREEN"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="LOAD_SANCTIONS"/>
<column name="entity_name" value="SANCTIONS"/>
<column name="action_name" value="LOAD"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
Sanctions screening activity log – records every name screening request and result.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- MySQL – m_sanctions_screening_log -->
<changeSet author="fineract" id="1" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_sanctions_screening_log"/></not>
</preConditions>
<createTable tableName="m_sanctions_screening_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="screened_name" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<!-- FLAGGED or CLEAR -->
<column name="screening_result" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="match_count" type="INTEGER" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- Comma-separated matched entry IDs for reference -->
<column name="matched_entry_ids" type="VARCHAR(1000)"/>
<!-- Comma-separated matched names for quick reference -->
<column name="matched_names" type="VARCHAR(2000)"/>
<!-- CLIENT_CREATION, MANUAL_CHECK, etc. -->
<column name="screening_source" type="VARCHAR(50)"/>
<!-- Optional: client ID if screening during client creation -->
<column name="client_id" type="BIGINT"/>
<column name="ip_address" type="VARCHAR(50)"/>
<column name="screened_on" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- PostgreSQL – m_sanctions_screening_log -->
<changeSet author="fineract" id="1" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_sanctions_screening_log"/></not>
</preConditions>
<createTable tableName="m_sanctions_screening_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="screened_name" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<column name="screening_result" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="match_count" type="INTEGER" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="matched_entry_ids" type="VARCHAR(1000)"/>
<column name="matched_names" type="VARCHAR(2000)"/>
<column name="screening_source" type="VARCHAR(50)"/>
<column name="client_id" type="BIGINT"/>
<column name="ip_address" type="VARCHAR(50)"/>
<column name="screened_on" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- Index on screened_name for searching logs -->
<changeSet author="fineract" id="2">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_sanctions_log_screened_name"/></not>
</preConditions>
<createIndex tableName="m_sanctions_screening_log" indexName="idx_sanctions_log_screened_name">
<column name="screened_name"/>
</createIndex>
</changeSet>
<!-- Index on screened_on for date range queries -->
<changeSet author="fineract" id="3">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_sanctions_log_screened_on"/></not>
</preConditions>
<createIndex tableName="m_sanctions_screening_log" indexName="idx_sanctions_log_screened_on">
<column name="screened_on"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
Mobile Number Lookup – activity log for DIP / MNRL lookups.
Table created:
m_mobile_lookup_log – logs every mobile number lookup request and response
Permissions inserted:
READ_MOBILE_LOOKUP
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- ═══════════════════════════════════════════════════════════════════════
MySQL – m_mobile_lookup_log
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="1" context="mysql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_mobile_lookup_log"/></not>
</preConditions>
<createTable tableName="m_mobile_lookup_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="mobile_number" type="VARCHAR(15)">
<constraints nullable="false"/>
</column>
<!-- CLEAN, SUSPECTED, DISCONNECTED, SUSPECTED_AND_DISCONNECTED, ERROR -->
<column name="lookup_status" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="suspected" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="disconnected" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="response_message" type="VARCHAR(500)"/>
<column name="raw_response" type="TEXT"/>
<!-- CLIENT_REGISTRATION, MANUAL_CHECK, API -->
<column name="lookup_source" type="VARCHAR(50)"/>
<column name="client_id" type="BIGINT"/>
<column name="looked_up_on" type="DATETIME">
<constraints nullable="false"/>
</column>
<!-- Audit fields -->
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- PostgreSQL – m_mobile_lookup_log -->
<changeSet author="fineract" id="1" context="postgresql">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_mobile_lookup_log"/></not>
</preConditions>
<createTable tableName="m_mobile_lookup_log">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true"/>
</column>
<column name="mobile_number" type="VARCHAR(15)">
<constraints nullable="false"/>
</column>
<column name="lookup_status" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="suspected" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="disconnected" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="response_message" type="VARCHAR(500)"/>
<column name="raw_response" type="TEXT"/>
<column name="lookup_source" type="VARCHAR(50)"/>
<column name="client_id" type="BIGINT"/>
<column name="looked_up_on" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<!-- Index on mobile_number for faster lookups -->
<changeSet author="fineract" id="2">
<preConditions onFail="MARK_RAN">
<not>
<indexExists indexName="idx_mobile_lookup_log_number"/>
</not>
</preConditions>
<createIndex tableName="m_mobile_lookup_log" indexName="idx_mobile_lookup_log_number">
<column name="mobile_number"/>
</createIndex>
</changeSet>
<!-- Index on looked_up_on for recent-first queries -->
<changeSet author="fineract" id="3">
<preConditions onFail="MARK_RAN">
<not>
<indexExists indexName="idx_mobile_lookup_log_looked_up_on"/>
</not>
</preConditions>
<createIndex tableName="m_mobile_lookup_log" indexName="idx_mobile_lookup_log_looked_up_on">
<column name="looked_up_on"/>
</createIndex>
</changeSet>
<!-- ═══════════════════════════════════════════════════════════════════════
Permissions
═══════════════════════════════════════════════════════════════════ -->
<changeSet author="fineract" id="4">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
SELECT COUNT(*) FROM m_permission WHERE code = 'READ_MOBILE_LOOKUP'
</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_MOBILE_LOOKUP"/>
<column name="entity_name" value="MOBILE_LOOKUP"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -35,10 +35,25 @@
</encoder> </encoder>
</appender> </appender>
<!-- File appender — logs saved to logs/fineract.log, rotated daily, max 30 days -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/fineract.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/fineract.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} : %msg%n</pattern>
</encoder>
</appender>
<!-- But these three INFO are still handy ;-) just to see when it's up and running --> <!-- But these three INFO are still handy ;-) just to see when it's up and running -->
<logger name="org.springframework.boot.web.embedded.tomcat.TomcatWebServer" level="info"/> <logger name="org.springframework.boot.web.embedded.tomcat.TomcatWebServer" level="info"/>
<logger name="org.apache.fineract.ServerApplication" level="info"/> <logger name="org.apache.fineract.ServerApplication" level="info"/>
<logger name="org.apache.fineract" level="${FINERACT_LOGGING_LEVEL:-INFO}"/> <logger name="org.apache.fineract" level="${FINERACT_LOGGING_LEVEL:-INFO}"/>
<!-- DEBUG for RBI Feed and OpenProject modules -->
<logger name="org.apache.fineract.infrastructure.rbifeed" level="DEBUG"/>
<logger name="org.apache.fineract.infrastructure.openproject" level="DEBUG"/>
<logger name="liquibase" level="warn"/> <logger name="liquibase" level="warn"/>
<if condition="${jsonLoggingEnabled} == true"> <if condition="${jsonLoggingEnabled} == true">
@@ -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 --> 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 -->
<root level="info"> <root level="info">
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root> </root>
</else> </else>
</if> </if>
+139
View File
@@ -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';

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