diff --git a/README.md b/README.md
index ed33d1816..69de6a2fd 100644
--- a/README.md
+++ b/README.md
@@ -313,6 +313,35 @@ Available languages:
| FINERACT_PLUGIN_OIDC_API_URL | Set the Client API URL | |
| FINERACT_PLUGIN_OIDC_FRONTEND_URL | Set the Front End URL callback | |
+#### External National ID System Integration
+
+These variables enable automatic lookup and auto-fill of client data from an external National ID system (e.g., Mexico's CURP) during client creation and editing.
+
+| Variable | Description | Default Value |
+| -------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
+| ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM | Set to `true` to enable External National ID lookup | false |
+| EXTERNAL_NATIONAL_ID_SYSTEM_URL | URL of the external National ID API | |
+| EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER | Header name for the external API key (e.g., `X-Gravitee-Api-Key`) | |
+| EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY | API key value (injected server-side via nginx; keep empty in source control) | |
+| EXTERNAL_NATIONAL_ID_REGEX | Regex pattern to validate the external ID format (e.g., CURP) | |
+| EXTERNAL_NATIONALID_API_URL | Full upstream URL for nginx proxy_pass | |
+
+When `ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM` is set to `true`, the following fields are auto-filled and disabled during client creation/editing after a successful lookup:
+
+- First Name
+- Middle Name
+- Last Name
+- Date of Birth
+- Gender
+
+The user types the External ID value, and if it matches the configured regex, the system calls the external API to retrieve and auto-fill client information.
+
+**Docker Compose with External National ID:**
+
+```bash
+docker-compose -f docker-compose.yml -f docker-compose.external-nationalid.yml up -d
+```
+
For more detailed configuration options, refer to the `env.sample` file in the root directory of the project.
### Client Data Masking Example
diff --git a/angular.json b/angular.json
index 2f4082a47..323a10ada 100644
--- a/angular.json
+++ b/angular.json
@@ -104,7 +104,8 @@
"serve": {
"builder": "@angular/build:dev-server",
"options": {
- "buildTarget": "mifosx-web-app:build"
+ "buildTarget": "mifosx-web-app:build",
+ "proxyConfig": "proxy.conf.js"
},
"configurations": {
"development": {
diff --git a/docker-compose.external-nationalid.yml b/docker-compose.external-nationalid.yml
new file mode 100644
index 000000000..a287e864e
--- /dev/null
+++ b/docker-compose.external-nationalid.yml
@@ -0,0 +1,28 @@
+# Docker Compose override for External National ID System integration
+# Usage: docker-compose -f docker-compose.yml -f docker-compose.external-nationalid.yml up -d
+#
+# This file extends the base docker-compose.yml to add:
+# - External National ID environment variables
+# - Custom nginx configuration with API key injection and rate limiting
+#
+# The nginx.conf.template is mounted as a volume to configure:
+# - Server-side API key injection via proxy_set_header (never exposed to browser)
+# - Rate limiting (2 req/s per IP, burst=5) to protect upstream API
+# - Path rewriting for /external-nationalid → upstream API
+
+version: '3.5'
+
+services:
+ mifosx-web-app:
+ volumes:
+ - ./nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
+ environment:
+ # External National ID System
+ - ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM=true
+ - EXTERNAL_NATIONAL_ID_SYSTEM_URL=/external-nationalid
+ # API header/key are injected server-side via nginx proxy_set_header
+ - EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER=X-Gravitee-Api-Key
+ - EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY=${EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY:-}
+ - EXTERNAL_NATIONAL_ID_REGEX=^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9]{2}$
+ # Full upstream URL for nginx proxy_pass
+ - EXTERNAL_NATIONALID_API_URL=${EXTERNAL_NATIONALID_API_URL:-https://apis.mifos.community/1.0/nationalid}
diff --git a/docker-compose.yml b/docker-compose.yml
index f27604bf0..5f0bb0086 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,5 +25,3 @@ services:
- MIFOS_SESSION_IDLE_TIMEOUT=300000
- MIFOS_PRELOAD_CLIENTS=true
- MIFOS_DEFAULT_CHAR_DELIMITER=,
- # Production mode - set to true for minimal hero with only branding
- - MIFOS_PRODUCTION_MODE=false
diff --git a/env.sample b/env.sample
index a130aff08..0d00c6c6b 100644
--- a/env.sample
+++ b/env.sample
@@ -50,3 +50,17 @@ MIFOS_PRODUCTION_MODE=false
# When true: Menus/buttons are shown based on user permissions (production mode)
# When false (default): All menus/buttons are shown (backward compatibility)
MIFOS_PRODUCTION_MODE_ENABLE_RBAC=false
+
+# External National ID System integration
+# Set to true to enable auto-lookup of client data from an external ID system
+ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM=false
+# URL of the external National ID API (proxied via /external-nationalid in dev)
+EXTERNAL_NATIONAL_ID_SYSTEM_URL=/external-nationalid
+# Header name for the external API key (injected server-side via nginx)
+EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER=X-Gravitee-Api-Key
+# API key value (keep empty in source control; injected server-side via nginx proxy_set_header)
+EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY=
+# Regex pattern to validate the external ID format (e.g. CURP)
+EXTERNAL_NATIONAL_ID_REGEX=^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9]{2}$
+# Full upstream URL for nginx proxy_pass (e.g. https://apis.mifos.community/1.0/nationalid)
+EXTERNAL_NATIONALID_API_URL=
diff --git a/nginx.conf.template b/nginx.conf.template
new file mode 100644
index 000000000..3a5a5c912
--- /dev/null
+++ b/nginx.conf.template
@@ -0,0 +1,59 @@
+# Rate limiting zone for external National ID API (2 requests/second per IP)
+limit_req_zone $binary_remote_addr zone=external_nationalid:10m rate=2r/s;
+
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Fineract API proxy
+ location /fineract-provider/ {
+ proxy_pass ${FINERACT_API_URL}/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # External National ID API proxy (only if configured)
+ location /external-nationalid {
+ # Use Docker DNS resolver for upstream resolution
+ resolver 127.0.0.11 valid=30s;
+
+ # Rate limit to prevent abuse of the upstream National ID API
+ limit_req zone=external_nationalid burst=5 nodelay;
+
+ # Read target from env var at runtime (set via envsubst on container start)
+ set $external_nationalid_target "${EXTERNAL_NATIONALID_API_URL}";
+
+ # Rewrite path: strip /external-nationalid prefix before proxying
+ rewrite ^/external-nationalid(.*)$ $1 break;
+
+ proxy_pass $external_nationalid_target;
+ proxy_set_header Host $proxy_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Inject API key server-side so it is never exposed to the browser
+ proxy_set_header ${EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER} "${EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY}";
+
+ # Pass through the original headers from the client
+ proxy_pass_request_headers on;
+
+ # CORS is not needed since requests come from the same origin
+ }
+
+ # Angular app - serve index.html for all routes
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Cache static assets
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
diff --git a/proxy.conf.js b/proxy.conf.js
index 9484effba..072f9c8d5 100644
--- a/proxy.conf.js
+++ b/proxy.conf.js
@@ -43,6 +43,38 @@ const proxyConfig = [
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
}
}
+ },
+ {
+ context: ['/external-nationalid'],
+ target: 'https://apis.mifos.community',
+ pathRewrite: { '^/external-nationalid': '/1.0/nationalid' },
+ changeOrigin: true,
+ secure: true,
+ logLevel: 'debug',
+ onProxyReq: function (proxyReq, req, res) {
+ // Inject API key server-side (same as nginx proxy_set_header in production)
+ const apiKey = process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY || '';
+ if (apiKey) {
+ proxyReq.setHeader('X-Gravitee-Api-Key', apiKey);
+ }
+ const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid');
+ console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
+ },
+ onError: function (err, req, res) {
+ console.error(
+ '[Proxy] Error while proxying request:',
+ req && req.method,
+ req && req.url,
+ '->',
+ this.target,
+ '-',
+ err && err.message
+ );
+ if (res && !res.headersSent) {
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
+ res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
+ }
+ }
}
// For local development use `proxy.localhost.conf js` .
];
diff --git a/proxy.localhost.conf.js b/proxy.localhost.conf.js
index 28a915b70..5f295e7e3 100644
--- a/proxy.localhost.conf.js
+++ b/proxy.localhost.conf.js
@@ -40,5 +40,37 @@ module.exports = [
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
}
}
+ },
+ {
+ context: ['/external-nationalid'],
+ target: 'https://apis.mifos.community',
+ pathRewrite: { '^/external-nationalid': '/1.0/nationalid' },
+ changeOrigin: true,
+ secure: true,
+ logLevel: 'debug',
+ onProxyReq: function (proxyReq, req, res) {
+ // Inject API key server-side (same as nginx proxy_set_header in production)
+ const apiKey = process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY || '';
+ if (apiKey) {
+ proxyReq.setHeader('X-Gravitee-Api-Key', apiKey);
+ }
+ const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid');
+ console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
+ },
+ onError: function (err, req, res) {
+ console.error(
+ '[Proxy] Error while proxying request:',
+ req && req.method,
+ req && req.url,
+ '->',
+ this.target,
+ '-',
+ err && err.message
+ );
+ if (res && !res.headersSent) {
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
+ res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
+ }
+ }
}
];
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
index 5e00fa397..ba69c7b13 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
@@ -39,9 +39,22 @@
{{ 'labels.inputs.External Id' | translate }}
+ @if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) {
+
+
+ {{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }}
+
+
+ }
- @if (createClientForm.contains('fullname')) {
+ @if (createClientForm.get('fullname')) {
{{ 'labels.inputs.' + getDateLabel(createClientForm.value.legalFormId, ['Name', 'Entity Name']) | translate }}
@@ -62,13 +75,9 @@
}
- @if (
- createClientForm.contains('firstname') ||
- createClientForm.contains('middlename') ||
- createClientForm.contains('lastname')
- ) {
+ @if (createClientForm.get('firstname') || createClientForm.get('middlename') || createClientForm.get('lastname')) {
- @if (createClientForm.contains('firstname')) {
+ @if (createClientForm.get('firstname')) {
{{ 'labels.inputs.First Name' | translate }}
@@ -87,7 +96,7 @@
}
}
- @if (createClientForm.contains('middlename')) {
+ @if (createClientForm.get('middlename')) {
{{ 'labels.inputs.Middle Name' | translate }}
@@ -100,7 +109,7 @@
}
}
- @if (createClientForm.contains('lastname')) {
+ @if (createClientForm.get('lastname')) {
{{ 'labels.inputs.Last Name' | translate }}
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss
index 45a6a8f47..c70f269b7 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss
@@ -123,6 +123,23 @@ mat-checkbox {
}
}
+.external-id-status {
+ font-size: 12px;
+ font-weight: 500;
+
+ &.loading {
+ color: #1976d2;
+ }
+
+ &.success {
+ color: #388e3c;
+ }
+
+ &.error {
+ color: #d32f2f;
+ }
+}
+
@media (width <= 480px) {
form {
padding: 8px 0;
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index d4649372f..d607e414c 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -20,6 +20,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum';
+import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
@@ -37,6 +38,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
selector: 'mifosx-client-general-step',
templateUrl: './client-general-step.component.html',
styleUrls: ['./client-general-step.component.scss'],
+ providers: [ExternalNationalIdService],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDivider,
@@ -52,6 +54,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
private clientService = inject(ClientsService);
+ externalNationalIdService = inject(ExternalNationalIdService);
/** Subject to trigger unsubscription on destroy */
private destroy$ = new Subject();
@@ -104,6 +107,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
this.maxDate = this.settingsService.businessDate;
this.setOptions();
this.buildDependencies();
+ this.externalNationalIdService.watchExternalId(this.createClientForm, this.genderOptions);
}
/**
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts
index 4d9ff8f80..776f1c4f3 100644
--- a/src/app/clients/clients.service.ts
+++ b/src/app/clients/clients.service.ts
@@ -8,12 +8,14 @@
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
-import { HttpClient, HttpParams } from '@angular/common/http';
+import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/common/http';
/** rxjs Imports */
import { Observable, of, throwError } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
+import { environment } from 'environments/environment';
+
/**
* Clients service.
*/
@@ -22,6 +24,10 @@ import { map, catchError } from 'rxjs/operators';
})
export class ClientsService {
private http = inject(HttpClient);
+ private httpBackend = inject(HttpBackend);
+
+ /** Separate HttpClient that bypasses interceptors (for external API calls) */
+ private externalHttp = new HttpClient(this.httpBackend);
getFilteredClients(
orderBy: string,
@@ -436,4 +442,34 @@ export class ClientsService {
}
return this.http.post(`/v2/clients/search`, request);
}
+
+ /**
+ * Lookup external National ID from the configured external system.
+ * Uses a separate HttpClient (via HttpBackend) to bypass Angular interceptors
+ * so that Fineract auth headers are not sent to the external API.
+ *
+ * In development, requests go through the dev proxy (/external-nationalid).
+ * In production, requests go through the nginx reverse proxy.
+ *
+ * @param externalId The National ID string (e.g. CURP)
+ */
+ lookupExternalNationalId(externalId: string): Observable {
+ const apiUrl = environment.externalNationalIdSystemUrl;
+ if (!apiUrl) {
+ return throwError(() => new Error('External National ID System URL is not configured'));
+ }
+
+ let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
+ const apiHeader = environment.externalNationalIdSystemApiHeader;
+ const apiKey = environment.externalNationalIdSystemApiKey;
+ if (apiHeader && apiKey) {
+ // Validate header name to prevent Angular from throwing on invalid header
+ if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(apiHeader)) {
+ return throwError(() => new Error(`Invalid API header name: '${apiHeader}'`));
+ }
+ headers = headers.set(apiHeader, apiKey);
+ }
+
+ return this.externalHttp.post(apiUrl, { externalId }, { headers });
+ }
}
diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html
index 2ccf2622f..83426769d 100644
--- a/src/app/clients/edit-client/edit-client.component.html
+++ b/src/app/clients/edit-client/edit-client.component.html
@@ -58,12 +58,25 @@
[placeholder]="'labels.inputs.External Id' | translate"
[title]="'labels.inputs.External Id' | translate"
/>
+ @if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) {
+
+
+ {{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }}
+
+
+ }
- @if (editClientForm.contains('fullname')) {
+ @if (editClientForm.get('fullname')) {
{{
'labels.inputs.' + getDateLabel(legalFormId, ['Name', 'Entity Name']) | translate
@@ -84,7 +97,7 @@
}
- @if (editClientForm.contains('firstname')) {
+ @if (editClientForm.get('firstname')) {
{{ 'labels.inputs.First Name' | translate }}
}
- @if (editClientForm.contains('middlename')) {
+ @if (editClientForm.get('middlename')) {
{{ 'labels.inputs.Middle Name' | translate }}
}
- @if (editClientForm.contains('lastname')) {
+ @if (editClientForm.get('lastname')) {
{{ 'labels.inputs.Last Name' | translate }}
+ * {{ statusMessageKey | translate }}
+ *
+ *
+ * // In service:
+ * this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.SUCCESS;
+ */
+export const EXTERNAL_ID_STATUS_KEYS = {
+ /** Successfully verified and retrieved client data from external API */
+ SUCCESS: 'External ID verified successfully',
+
+ /** External ID not found in external system (404 response) */
+ NOT_FOUND: 'External ID not found',
+
+ /** External ID format is invalid (client-side regex validation failed) */
+ INVALID_FORMAT: 'External ID format is invalid',
+
+ /** API call timed out after 10 seconds */
+ TIMEOUT: 'External ID lookup timed out',
+
+ /** Generic API error (5xx, network error, etc.) */
+ FAILED: 'External ID lookup failed',
+
+ /** Currently fetching data from external API */
+ LOADING: 'Looking up External ID...',
+
+ /** No status message (reset state) */
+ EMPTY: ''
+} as const;
+
+/**
+ * Type representing all possible status message keys.
+ * Useful for type-safe status handling in components.
+ *
+ * @example
+ * let currentStatus: ExternalIdStatusKey = EXTERNAL_ID_STATUS_KEYS.SUCCESS;
+ */
+export type ExternalIdStatusKey = (typeof EXTERNAL_ID_STATUS_KEYS)[keyof typeof EXTERNAL_ID_STATUS_KEYS];
+
+/**
+ * Mapping from external API gender IDs to gender names.
+ * The external API uses different IDs (36=Male, 37=Female) than Fineract.
+ * We map by name to find the corresponding Fineract gender option.
+ *
+ * @example
+ * const genderName = EXTERNAL_GENDER_MAP[36]; // 'Male'
+ * const fineractOption = genderOptions.find(opt => opt.name === genderName);
+ */
+export const EXTERNAL_GENDER_MAP: Readonly> = {
+ 36: 'Male',
+ 37: 'Female'
+};
+
+/**
+ * Fields that are auto-filled and disabled when external ID is verified.
+ * These are person-specific fields that should not exist for Entity legal forms.
+ */
+export const PERSON_FIELD_NAMES = [
+ 'firstname',
+ 'middlename',
+ 'lastname',
+ 'dateOfBirth',
+ 'genderId'
+] as const;
+
+/**
+ * Type representing the person field names.
+ * Useful for type-safe field access in components.
+ */
+export type PersonFieldName = (typeof PERSON_FIELD_NAMES)[number];
diff --git a/src/app/clients/services/external-national-id.service.ts b/src/app/clients/services/external-national-id.service.ts
new file mode 100644
index 000000000..f205d471c
--- /dev/null
+++ b/src/app/clients/services/external-national-id.service.ts
@@ -0,0 +1,308 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { inject, Injectable, OnDestroy } from '@angular/core';
+import { UntypedFormGroup } from '@angular/forms';
+import { Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, timeout, catchError } from 'rxjs/operators';
+import { of } from 'rxjs';
+import { environment } from 'environments/environment';
+import { ClientsService } from '../clients.service';
+import {
+ EXTERNAL_ID_STATUS_KEYS,
+ EXTERNAL_GENDER_MAP,
+ PERSON_FIELD_NAMES,
+ type PersonFieldName,
+ type ExternalIdStatusKey
+} from './external-national-id.constants';
+
+/** Response shape from the external National ID API */
+export interface ExternalNationalIdResponse {
+ firstname?: string;
+ middlename?: string;
+ lastname?: string;
+ dateOfBirth?: string;
+ dateFormat?: string;
+ genderId?: number;
+ developerMessage?: string;
+}
+
+/** Fineract gender option (from client template) */
+export interface GenderOption {
+ id: number;
+ name: string;
+ active?: boolean;
+ mandatory?: boolean;
+}
+
+/**
+ * Shared service for External National ID System integration.
+ *
+ * Provided at the component level (not `providedIn: 'root'`) so each component
+ * gets its own instance with independent lifecycle. The service's `ngOnDestroy`
+ * is called when the host component is destroyed, cleaning up subscriptions.
+ *
+ * When enabled, watches the externalId form control:
+ * - Validates input against configured regex (e.g. CURP)
+ * - Calls external API to lookup client data
+ * - Auto-fills firstname, middlename, lastname, dateOfBirth, genderId
+ * - Disables those fields so users cannot modify API-provided values
+ */
+@Injectable()
+export class ExternalNationalIdService implements OnDestroy {
+ /** Whether the feature is enabled via env var */
+ readonly enabled: boolean;
+
+ /**
+ * Compiled regex pattern for external ID validation.
+ * Compiled once in the constructor to avoid repeated `new RegExp()` calls (CWE-1333 mitigation).
+ * Null if regex is not configured or is invalid.
+ */
+ private readonly pattern: RegExp | null;
+
+ /** Status message key for the template to display */
+ statusMessageKey: ExternalIdStatusKey = EXTERNAL_ID_STATUS_KEYS.EMPTY;
+
+ /** Whether a lookup is currently in progress */
+ isLoading = false;
+
+ private destroy$ = new Subject();
+
+ private clientsService = inject(ClientsService);
+
+ constructor() {
+ this.enabled = environment.enableExternalNationalIdSystem === true;
+
+ const regexStr = environment.externalNationalIdRegex;
+ if (regexStr) {
+ try {
+ this.pattern = new RegExp(regexStr);
+ } catch {
+ console.warn('[ExternalNationalId] Invalid regex pattern:', regexStr);
+ this.pattern = null;
+ }
+ } else {
+ this.pattern = null;
+ }
+ }
+
+ /**
+ * Validates an external ID against the compiled regex pattern.
+ */
+ isValidExternalId(value: string): boolean {
+ if (!value || !this.pattern) {
+ return false;
+ }
+ return this.pattern.test(value.trim());
+ }
+
+ /**
+ * Start watching the externalId form control for changes.
+ * When a valid ID is entered, calls the external API and fills form fields.
+ *
+ * @param form The reactive form containing externalId and person fields
+ * @param genderOptions Array of gender options from the Fineract template
+ * @param skipInitialValue If true, skips lookup for the current form value (used in edit mode
+ * to avoid re-fetching data for an already-saved external ID)
+ */
+ watchExternalId(form: UntypedFormGroup, genderOptions: GenderOption[], skipInitialValue = false): void {
+ if (!this.enabled) {
+ return;
+ }
+
+ const externalIdCtrl = form.get('externalId');
+ if (!externalIdCtrl) {
+ return;
+ }
+
+ // In edit mode, if the client already has a valid external ID, lock the fields
+ // without making an API call
+ if (skipInitialValue && externalIdCtrl.value && this.isValidExternalId(externalIdCtrl.value)) {
+ this.disablePersonFields(form);
+ return;
+ }
+
+ externalIdCtrl.valueChanges
+ .pipe(
+ debounceTime(500),
+ distinctUntilChanged(),
+ filter((value: string) => {
+ if (!value || !this.isValidExternalId(value)) {
+ // Re-enable fields if ID is cleared or invalid
+ this.enablePersonFields(form);
+ if (value && value.length > 3 && !this.isValidExternalId(value)) {
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.INVALID_FORMAT;
+ } else {
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.EMPTY;
+ }
+ return false;
+ }
+ return true;
+ }),
+ switchMap((value: string) => {
+ this.isLoading = true;
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.LOADING;
+ return this.clientsService.lookupExternalNationalId(value).pipe(
+ timeout(10000),
+ catchError((error) => {
+ this.isLoading = false;
+ if (error.name === 'TimeoutError') {
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.TIMEOUT;
+ } else if (error.status === 404) {
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.NOT_FOUND;
+ } else {
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.FAILED;
+ }
+ this.enablePersonFields(form);
+ return of(null);
+ })
+ );
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe((response: ExternalNationalIdResponse | null) => {
+ this.isLoading = false;
+ if (response) {
+ this.fillFormFromResponse(form, response, genderOptions);
+ }
+ });
+ }
+
+ /**
+ * Fill form fields from the external API response and disable them.
+ * Guards each control access to handle the case where the legal form changes
+ * while a lookup is in-flight (e.g. controls removed by buildDependencies).
+ */
+ private fillFormFromResponse(
+ form: UntypedFormGroup,
+ response: ExternalNationalIdResponse,
+ genderOptions: GenderOption[]
+ ): void {
+ const devMessage = response.developerMessage || '';
+
+ if (devMessage === 'client.externalid.notfound') {
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.NOT_FOUND;
+ this.enablePersonFields(form);
+ return;
+ }
+
+ if (devMessage === 'client.externalid.invalid') {
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.INVALID_FORMAT;
+ this.enablePersonFields(form);
+ return;
+ }
+
+ // Valid response — fill and disable fields
+ this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.SUCCESS;
+
+ const firstnameCtrl = form.get('firstname');
+ const middlenameCtrl = form.get('middlename');
+ const lastnameCtrl = form.get('lastname');
+ const dobCtrl = form.get('dateOfBirth');
+ const genderCtrl = form.get('genderId');
+
+ // Guard: if person-specific controls were removed (legal form changed to Entity
+ // while lookup was in-flight), skip those fields gracefully
+ if (firstnameCtrl && response.firstname) {
+ firstnameCtrl.setValue(response.firstname);
+ firstnameCtrl.disable();
+ }
+
+ if (middlenameCtrl && response.middlename) {
+ middlenameCtrl.setValue(response.middlename);
+ middlenameCtrl.disable();
+ }
+
+ if (lastnameCtrl && response.lastname) {
+ lastnameCtrl.setValue(response.lastname);
+ lastnameCtrl.disable();
+ }
+
+ if (dobCtrl && response.dateOfBirth) {
+ const parsedDate = this.parseDate(response.dateOfBirth, response.dateFormat);
+ if (parsedDate) {
+ dobCtrl.setValue(parsedDate);
+ dobCtrl.disable();
+ }
+ }
+
+ if (genderCtrl && response.genderId != null) {
+ const genderName = EXTERNAL_GENDER_MAP[response.genderId];
+ const matchedGender = genderName
+ ? genderOptions?.find((g: GenderOption) => g.name?.toLowerCase() === genderName.toLowerCase())
+ : null;
+ if (matchedGender) {
+ genderCtrl.setValue(matchedGender.id);
+ genderCtrl.disable();
+ }
+ }
+ }
+
+ /**
+ * Parse a date string using the provided format.
+ * Validates the parsed result against the expected format to catch mismatches.
+ *
+ * @param dateStr The date string from the API (e.g. "15/03/1990")
+ * @param dateFormat The format hint from the API (e.g. "dd/MM/yyyy")
+ * @returns A valid Date object or null if parsing fails
+ */
+ parseDate(dateStr: string, dateFormat?: string): Date | null {
+ if (!dateStr) {
+ return null;
+ }
+
+ const format = dateFormat || 'dd/MM/yyyy';
+ const parts = dateStr.split('/');
+
+ if (format === 'dd/MM/yyyy' && parts.length === 3) {
+ const day = parseInt(parts[0], 10);
+ const month = parseInt(parts[1], 10) - 1;
+ const year = parseInt(parts[2], 10);
+
+ // Validate ranges before constructing
+ if (day < 1 || day > 31 || month < 0 || month > 11 || year < 1900 || year > 2100) {
+ return null;
+ }
+
+ const date = new Date(year, month, day);
+
+ // Verify the date components match (catches invalid dates like Feb 30)
+ if (isNaN(date.getTime()) || date.getDate() !== day || date.getMonth() !== month || date.getFullYear() !== year) {
+ return null;
+ }
+ return date;
+ }
+
+ // Fallback: try native parsing
+ const fallback = new Date(dateStr);
+ return isNaN(fallback.getTime()) ? null : fallback;
+ }
+
+ /**
+ * Re-enable person fields so they can be edited manually.
+ */
+ enablePersonFields(form: UntypedFormGroup): void {
+ for (const field of PERSON_FIELD_NAMES) {
+ form.get(field)?.enable();
+ }
+ }
+
+ /**
+ * Disable person fields (used in edit mode to lock fields for existing external IDs).
+ */
+ private disablePersonFields(form: UntypedFormGroup): void {
+ for (const field of PERSON_FIELD_NAMES) {
+ form.get(field)?.disable();
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
diff --git a/src/assets/env.js b/src/assets/env.js
index 93c20a168..469251823 100644
--- a/src/assets/env.js
+++ b/src/assets/env.js
@@ -91,6 +91,16 @@
// Set to true to enable RBAC, false (default) for backward compatibility
window['env']['productionModeEnableRBAC'] = false;
+ // External National ID System
+ // Set to 'true' to enable External National ID lookup during client creation/editing
+ // When enabled, set EXTERNAL_NATIONAL_ID_SYSTEM_URL, API_HEADER, API_KEY, and REGEX
+ // In production, API key is injected server-side via nginx proxy_set_header (never set here)
+ window['env']['enableExternalNationalIdSystem'] = 'false';
+ window['env']['externalNationalIdSystemUrl'] = '';
+ window['env']['externalNationalIdSystemApiHeader'] = '';
+ window['env']['externalNationalIdSystemApiKey'] = '';
+ window['env']['externalNationalIdRegex'] = '';
+
// OIDC Plugin Environment variables
window['env']['oidcServerEnabled'] = false;
window['env']['oidcBaseUrl'] = '';
diff --git a/src/assets/env.template.js b/src/assets/env.template.js
index 8f748fb95..da0d11452 100644
--- a/src/assets/env.template.js
+++ b/src/assets/env.template.js
@@ -103,6 +103,14 @@
// Set to 'true' to enable RBAC, 'false' (default) for backward compatibility
window['env']['productionModeEnableRBAC'] = '$MIFOS_PRODUCTION_MODE_ENABLE_RBAC';
+ // External National ID System Integration
+ // API key is injected server-side via nginx proxy_set_header — not exposed to browser
+ window['env']['enableExternalNationalIdSystem'] = '$ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM';
+ window['env']['externalNationalIdSystemUrl'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_URL';
+ window['env']['externalNationalIdSystemApiHeader'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER';
+ window['env']['externalNationalIdSystemApiKey'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY';
+ window['env']['externalNationalIdRegex'] = '$EXTERNAL_NATIONAL_ID_REGEX';
+
// OIDC Plugin Environment variables
window['env']['oidcServerEnabled'] = '$FINERACT_PLUGIN_OIDC_ENABLED';
window['env']['oidcBaseUrl'] = '$FINERACT_PLUGIN_OIDC_BASE_URL';
diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json
index 2a2b90619..ae2981971 100644
--- a/src/assets/translations/en-US.json
+++ b/src/assets/translations/en-US.json
@@ -1760,6 +1760,12 @@
"External ID": "External ID",
"External Id": "External Id",
"External id": "External id",
+ "External ID not found": "External ID not found",
+ "External ID lookup failed": "External ID lookup failed",
+ "External ID format is invalid": "External ID format is invalid",
+ "External ID verified successfully": "External ID verified successfully",
+ "Looking up External ID...": "Looking up External ID...",
+ "External ID lookup timed out": "External ID lookup timed out",
"event with entity name of": "event with entity name of",
"FAMILY MEMBERS": "FAMILY MEMBERS",
"FCM End Point": "FCM End Point",
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index b10101ca9..51c0849b4 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -85,6 +85,13 @@ export const environment = {
minPasswordLength: loadedEnv['minPasswordLength'] || 12,
+ /** External National ID System integration */
+ enableExternalNationalIdSystem: loadedEnv['enableExternalNationalIdSystem'] === 'true' || false,
+ externalNationalIdSystemUrl: loadedEnv['externalNationalIdSystemUrl'] || '',
+ externalNationalIdSystemApiHeader: loadedEnv['externalNationalIdSystemApiHeader'] || '',
+ externalNationalIdSystemApiKey: loadedEnv['externalNationalIdSystemApiKey'] || '',
+ externalNationalIdRegex: loadedEnv['externalNationalIdRegex'] || '',
+
/**
* Hide client data information (mask client names with *)
* Set via MIFOS_COMPLIANCE_HIDE_CLIENT_DATA env var
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index e364c711d..c25c2bc63 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -85,6 +85,17 @@ export const environment = {
mifosInterbankTransfersApiVersion: window.env?.mifosInterbankTransfersApiVersion || '/v1.0',
mifosInterbankTransfersEnabled: window.env?.mifosInterbankTransfersEnabled ?? true,
+ /**
+ * External National ID System Integration
+ * When enabled, client creation/editing will lookup external National ID
+ * and auto-fill client details (name, DOB, gender) from the external system.
+ */
+ enableExternalNationalIdSystem: loadedEnv.enableExternalNationalIdSystem === 'true' || false,
+ externalNationalIdSystemUrl: loadedEnv.externalNationalIdSystemUrl || '',
+ externalNationalIdSystemApiHeader: loadedEnv.externalNationalIdSystemApiHeader || '',
+ externalNationalIdSystemApiKey: loadedEnv.externalNationalIdSystemApiKey || '',
+ externalNationalIdRegex: loadedEnv.externalNationalIdRegex || '',
+
minPasswordLength: loadedEnv.minPasswordLength || 12,
/**
diff --git a/src/typings.d.ts b/src/typings.d.ts
index cb2e8bc89..9b57417dd 100644
--- a/src/typings.d.ts
+++ b/src/typings.d.ts
@@ -41,6 +41,11 @@ interface Window {
mifosInterbankTransfersApiVersion?: string;
mifosInterbankTransfersEnabled?: boolean;
minPasswordLength?: number;
+ enableExternalNationalIdSystem?: string;
+ externalNationalIdSystemUrl?: string;
+ externalNationalIdSystemApiHeader?: string;
+ externalNationalIdSystemApiKey?: string;
+ externalNationalIdRegex?: string;
};
}