Merge remote-tracking branch 'origin/uate' into uate

This commit is contained in:
2026-07-29 10:29:55 +05:30
16 changed files with 1808 additions and 29 deletions
@@ -136,7 +136,7 @@
| translate
}}
</mat-label>
<input matInput required formControlName="fullname" />
<input matInput required formControlName="fullname" autocomplete="off" />
@if (createClientForm.controls.fullname.hasError('required')) {
<mat-error>Client name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
@@ -214,7 +214,7 @@
<mat-form-field appearance="outline" class="name-field first-name">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
<input matInput required formControlName="firstname" />
<input matInput required formControlName="firstname" autocomplete="off" />
@if (createClientForm.controls.firstname.hasError('required')) {
<mat-error>First name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
@@ -228,7 +228,7 @@
<mat-form-field appearance="outline" class="name-field middle-name">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
<input matInput formControlName="middlename" />
<input matInput formControlName="middlename" autocomplete="off" />
@if (
createClientForm.controls.middlename?.hasError('pattern') &&
createClientForm.controls.middlename?.touched
@@ -241,7 +241,7 @@
<mat-form-field appearance="outline" class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
<input matInput required formControlName="lastname" />
<input matInput required formControlName="lastname" autocomplete="off" />
@if (createClientForm.controls.lastname.hasError('required')) {
<mat-error>Last name is <strong>required</strong></mat-error>
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
@@ -108,6 +108,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
pwdCategoryOptions: any[] = [];
riskRules: any[] = [];
/** Set true after a save attempt so the UI can highlight a missing required document */
seniorPwdDocsTouched = false;
@@ -546,6 +547,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.setOptions();
this.clientService.getClientRiskRules().subscribe((rules) => {
this.riskRules = rules || [];
});
// Fetch marital status options from code values
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
this.maritalStatusOptions = options || [];
@@ -2120,6 +2125,74 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return generalDetails;
}
get calculatedRisk(): { category: number; reason: string } {
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
const occId = this.createClientForm.get('occupationCvId')?.value;
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) {
// Match legal form
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
continue;
}
// Match occupation category
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
continue;
}
// Match occupation
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
continue;
}
// Match monthly income ranges
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
continue;
}
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
continue;
}
// Match annual income ranges (evaluated against totalAnnualIncome)
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
continue;
}
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
continue;
}
// Match turnover ranges
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
continue;
}
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
continue;
}
// All matched!
return { category: rule.riskCategory, reason: rule.reason };
}
}
// Default Fallbacks
if (legalFormId !== LegalFormId.PERSON) {
return { category: 2, reason: 'Non-Individual / Entity profile (Default Medium Risk)' };
}
return { category: 1, reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)' };
}
}
/**
@@ -49,6 +49,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
@Input() isMinor = false;
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
@Input() isNonIndividual = false;
activeTab = 0;
sameAsPermanent = false;
@@ -282,8 +286,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.addressProofs = this.filterAddressProofsForMinor(
(res?.addressProofs || []).filter(activeFilter).map(normalizeProofType)
this.addressProofs = this.filterAddressProofsForNonIndividual(
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
);
this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false;
@@ -309,6 +313,26 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
);
}
/** Personal OVDs — not valid proof of a registered office/entity address. */
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
'AADHAAR',
'PASSPORT',
'VOTER_ID',
'DRIVING_LICENSE',
'NREGA_JOB_CARD',
'NPR_LETTER',
'BIRTH_CERTIFICATE',
'SCHOOL_ID_CARD'
]);
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
if (!this.isNonIndividual) return proofs;
return proofs.filter(
(p) =>
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
);
}
selectAddrProof(proof: ProofType): void {
this.selectedAddrProof = proof;
this.permanentForm.get('proofId')?.setValue(proof.id);
@@ -425,6 +449,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
}
}
}
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
// `addressProofs` in place, since the non-individual filter only ever removes entries and
// toggling back to individual could never recover what a prior pass had stripped out.
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
this.selectedAddrProof = null;
this.permanentForm.get('proofId')?.setValue('');
this.loadProofTypes();
}
}
/**
@@ -1049,6 +1049,12 @@
</h4>
<div class="form-grid">
<div class="checkbox-field override-checkbox-wrapper full-width">
<mat-checkbox formControlName="allowRiskOverride" [disabled]="readOnly">
Override Auto-calculated Risk Category
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Risk Category</mat-label>
<mat-select formControlName="riskCategory">
@@ -1086,6 +1092,64 @@
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
</mat-form-field>
@if (riskCalculationReason && !kycForm.get('allowRiskOverride')?.value && !readOnly) {
<div
class="risk-auto-calc-info full-width"
[class.risk-auto-calc-info--low]="kycForm.get('riskCategory')?.value === 1"
[class.risk-auto-calc-info--medium]="kycForm.get('riskCategory')?.value === 2"
[class.risk-auto-calc-info--high]="kycForm.get('riskCategory')?.value === 3"
>
<mat-icon class="info-icon">info</mat-icon>
<span class="info-text"
>Auto-determined: <strong>{{ riskCalculationReason }}</strong></span
>
</div>
}
<div class="risk-rules-toggle-wrapper full-width">
<button mat-button type="button" class="rules-toggle-btn" (click)="showRiskRulesTable = !showRiskRulesTable">
<mat-icon class="rules-toggle-icon">{{ showRiskRulesTable ? 'expand_less' : 'expand_more' }}</mat-icon>
{{
showRiskRulesTable ? 'Hide Risk Classification Criteria Table' : 'View Risk Classification Criteria Table'
}}
</button>
</div>
@if (showRiskRulesTable) {
<div class="risk-rules-card mat-elevation-z1 full-width">
<table class="risk-rules-table">
<thead>
<tr>
<th>Risk Category</th>
<th>Primary Criteria / Basis</th>
<th>Examples / Thresholds</th>
</tr>
</thead>
<tbody>
<tr class="rule-row rule-row--high">
<td><span class="risk-badge risk-badge--high">High Risk</span></td>
<td>PEPs, High-Risk Industries, High Income / Turnovers</td>
<td>
Politicians; Real Estate, Import/Export; Commission Agents; Income > ₹20L/yr; Turnover > ₹1 Crore
</td>
</tr>
<tr class="rule-row rule-row--medium">
<td><span class="risk-badge risk-badge--medium">Medium Risk</span></td>
<td>Self-Employed, Professionals, Cross-Border, Moderate Income</td>
<td>
Business Owners, Traders; Doctors, Lawyers, CAs; NRI, PIO, OCI; Income > ₹6L/yr; Turnover > ₹20L
</td>
</tr>
<tr class="rule-row rule-row--low">
<td><span class="risk-badge risk-badge--low">Low Risk</span></td>
<td>Non-Earning, Government Employment, Agriculture / Manual</td>
<td>Students, Minors, Homemakers; Govt/PSU Employees; Farmers, Daily Wage; Income <= ₹6L/yr</td>
</tr>
</tbody>
</table>
</div>
}
<mat-form-field appearance="outline" class="form-field full-width">
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
<textarea
@@ -2257,3 +2257,161 @@
width: 95px;
}
}
.override-checkbox-wrapper {
margin-bottom: 8px;
}
.risk-auto-calc-info {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
margin-top: -8px;
margin-bottom: 12px;
border: 1px solid transparent;
.info-icon {
font-size: 18px !important;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.info-text strong {
font-weight: 600;
}
&--low {
background: #ecfdf5;
color: #065f46;
border-color: #a7f3d0;
.info-icon {
color: #10b981;
}
}
&--medium {
background: #fffbeb;
color: #92400e;
border-color: #fde68a;
.info-icon {
color: #f59e0b;
}
}
&--high {
background: #fef2f2;
color: #991b1b;
border-color: #fecaca;
.info-icon {
color: #ef4444;
}
}
}
.risk-rules-toggle-wrapper {
margin-top: 4px;
margin-bottom: 12px;
.rules-toggle-btn {
font-size: 13px;
font-weight: 600;
color: #1e6bb8;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
transition: all 0.2s ease-in-out;
background: #f1f5f9;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
}
.risk-rules-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
}
.risk-rules-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
th {
padding: 10px 12px;
font-weight: 600;
color: #475569;
border-bottom: 2px solid #cbd5e1;
background: #f8fafc;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 12px;
border-bottom: 1px solid #e2e8f0;
color: #334155;
vertical-align: top;
line-height: 1.5;
}
tr:last-child td {
border-bottom: none;
}
.rule-row {
transition: background 0.15s ease-in-out;
&:hover {
background: #f8fafc;
}
}
.risk-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
text-align: center;
width: 80px;
&--high {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
&--medium {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
&--low {
background: #ecfdf5;
color: #16a34a;
border: 1px solid #a7f3d0;
}
}
}
@@ -70,6 +70,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
@Input() entityName: string = '';
/** Read-only mode — disables form editing until user clicks edit icon */
@Input() readOnly = false;
@Input() calculatedRisk: { category: number; reason: string } | null = null;
today = new Date();
kycId: string = '';
@@ -170,6 +171,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
];
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
* Annexure I) — shown for non-individual clients before a specific constitution type is
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
private nonIndividualDefaultDocTypes = [
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
];
/** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
HUF: [
@@ -250,6 +262,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
];
riskCalculationReason = '';
showRiskRulesTable = false;
/** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false;
@@ -441,6 +456,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocIssueDate: [''],
kycDocExpiryDate: [''],
riskCategory: [1],
allowRiskOverride: [false],
remarks: [''],
amlCheckStatus: [
'CLEAR',
@@ -488,6 +504,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
return docs.filter((d) => !ClientKycStepComponent.MINOR_ONLY_DOC_CODES.has((d.code || '').toUpperCase()));
}
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
* ever applies to individuals. */
private getDefaultDocTypes(): { name: string; code: string }[] {
return this.isNonIndividual
? this.nonIndividualDefaultDocTypes
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
}
ngOnInit(): void {
this.seedDefaultDocTypes();
this.loadExistingKycIfNeeded();
@@ -495,20 +519,36 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
if (this.isNonIndividual && this.constitutionMeta) {
this.updateDocTypesForConstitution();
}
this.kycForm.get('allowRiskOverride')?.valueChanges.subscribe((allowed: boolean) => {
const riskControl = this.kycForm.get('riskCategory');
if (allowed) {
if (!this.readOnly) {
riskControl?.enable();
}
} else {
riskControl?.disable();
if (this.calculatedRisk) {
riskControl?.setValue(this.calculatedRisk.category);
}
}
});
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable();
}
}
/** Seed the document-type picker from defaults, filtered by the current isMinor input.
* Only seeds when the list is empty — the template API (loadKycTemplate) takes over once
* a clientId is available. */
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
* filtered by the current isMinor input for individuals). Only seeds when the list is
* empty — the template API (loadKycTemplate) takes over once a clientId is available. */
private seedDefaultDocTypes(): void {
if (this.kycDocumentTypes.length) return;
this.kycDocumentTypes = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
@@ -526,6 +566,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.reset({
hasPan: true,
riskCategory: 1,
allowRiskOverride: false,
amlCheckStatus: 'CLEAR',
bannedEntityMatch: 'NO',
willfulDefaulter: 'NO',
@@ -546,6 +587,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
this.existingKycIdentifierId = null;
this.riskCalculationReason = '';
this.showRiskRulesTable = false;
this.existingKycDocuments = [];
this.photoSigTouched = false;
this.panVerifyStatus = 'NONE';
@@ -598,12 +641,41 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.disable({ emitEvent: false });
} else {
this.kycForm.enable({ emitEvent: false });
if (!this.kycForm.get('allowRiskOverride')?.value) {
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
}
}
}
if (changes['calculatedRisk']) {
const newRisk = changes['calculatedRisk'].currentValue;
if (newRisk) {
this.riskCalculationReason = newRisk.reason;
const allowOverride = this.kycForm.get('allowRiskOverride')?.value;
const riskControl = this.kycForm.get('riskCategory');
if (!allowOverride && riskControl && riskControl.value !== newRisk.category) {
riskControl.setValue(newRisk.category);
riskControl.markAsDirty();
}
} else {
this.riskCalculationReason = '';
}
}
if (changes['constitutionMeta'] && this.isNonIndividual) {
this.updateDocTypesForConstitution();
}
if (changes['isMinor']) {
// Legal form toggled on an already-initialized instance (route-reuse keeps this
// component alive across create sessions) — the individual OVD list and the entity RBI
// doc list are mutually exclusive, so re-seed from scratch rather than filtering in place.
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange && !this.kycTemplateLoaded) {
this.kycDocumentTypes = [];
this.selectedKycDoc = null;
this.kycForm.get('kycDocumentName')?.setValue('');
this.seedDefaultDocTypes();
if (this.isNonIndividual && this.constitutionMeta) {
this.updateDocTypesForConstitution();
}
}
if (changes['isMinor'] && !this.isNonIndividual) {
if (this.isMinor) {
this.kycForm.get('hasPan')?.setValue(false);
this.kycForm.get('panNumber')?.setValue('');
@@ -797,6 +869,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
// Risk & Remarks
riskCategory: data.riskCategory || 1,
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
remarks: data.remarks || '',
hasPan: !!data.panNumber,
// AML & Compliance
@@ -1015,13 +1088,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.filterDocTypesForMinor(
this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}))
);
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
});
@@ -1664,7 +1735,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}
get kycData(): any {
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
const formVal = this.kycForm.getRawValue();
const aadhaarRaw = formVal.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
const dateFormat = this.settingsService.dateFormat;
+4
View File
@@ -776,6 +776,10 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/kyc/template`);
}
getClientRiskRules(): Observable<any[]> {
return this.http.get<any[]>('/kyc/risk-rules');
}
/** Fetch code values by code ID */
getCodeValuesById(codeId: number): Observable<any[]> {
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
@@ -76,6 +76,7 @@
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
</div>
@@ -148,6 +149,7 @@
<mifosx-client-india-address-step
#clientIndiaAddress
[isMinor]="clientGeneralStep?.isMinor || false"
[isNonIndividual]="legalFormType === 2"
[readOnly]="isTabReadOnly(addressTabIdx)"
></mifosx-client-india-address-step>
</div>
@@ -165,6 +165,7 @@
[isMinor]="clientGeneralStep?.isMinor || false"
[clientId]="clientId"
[readOnly]="isTabReadOnly(1)"
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
></mifosx-client-kyc-step>
</div>
</div>
@@ -221,6 +222,7 @@
#clientIndiaAddress
[clientId]="clientId"
[isMinor]="clientGeneralStep?.isMinor || false"
[isNonIndividual]="legalFormType === 2"
[readOnly]="isTabReadOnly(addressTabIdx)"
></mifosx-client-india-address-step>
</div>
+38
View File
@@ -146,6 +146,44 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
sides: 1,
category: 'Financial & other',
info: 'Pension payment order — upload full document issued by pension authority.'
},
CERT_INCORPORATION: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'Registration / incorporation number',
documentKeyPlaceholder: 'CIN / registration number',
info: 'Certificate of Incorporation (companies/LLPs) or Registration Certificate (firms/trusts/societies) — as prescribed under the RBI Master Direction on KYC, Annexure I.'
},
ENTITY_PAN: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'PAN of entity',
documentKeyPlaceholder: 'e.g. ABCDE1234F',
info: 'PAN card issued in the name of the entity.'
},
CONSTITUTIVE_DOC: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'Document reference number',
documentKeyPlaceholder: 'Reference / registration number',
info: 'Constitutive document — Memorandum & Articles of Association (companies), Partnership Deed (firms), Trust Deed (trusts), or By-laws (societies) — as prescribed under the RBI Master Direction on KYC.'
},
BOARD_RESOLUTION: {
sides: 1,
category: 'Entity KYC documents',
info: 'Board Resolution / Authorization Letter naming the officers, managers or employees authorised to transact on the entity’s behalf.'
},
AUTH_SIGNATORY_KYC: {
sides: 1,
category: 'Entity KYC documents',
info: 'OVD-based KYC of the person(s) holding Power of Attorney / authorised to transact on the entity’s behalf.'
},
GST_CERTIFICATE: {
sides: 1,
category: 'Entity KYC documents',
documentKeyLabel: 'GSTIN',
documentKeyPlaceholder: 'e.g. 22AAAAA0000A1Z5',
info: 'GST Registration Certificate, where the entity is GST-registered.'
}
};
+6 -2
View File
@@ -1568,8 +1568,12 @@
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('CRR / SLR')" (click)="rbiMenuOpen = false">
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CRR / SLR
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('CTR / STR')"
(click)="rbiMenuOpen = false; openCtrStrDialog()"
>
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
</a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
+17
View File
@@ -280,6 +280,23 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Opens the CTR & STR Compliance Center dialog.
*/
async openCtrStrDialog(): Promise<void> {
const { CtrStrDialogComponent } = await import('../../home/dashboard/ctr-str-dialog/ctr-str-dialog.component');
this.dialog.open(CtrStrDialogComponent, {
width: '95vw',
maxWidth: '1280px',
maxHeight: '92vh',
panelClass: 'crr-slr-dialog-panel',
autoFocus: false,
disableClose: false,
hasBackdrop: true,
backdropClass: 'ckyc-rpt-dlg-backdrop'
});
}
/**
* Opens the CIMS / XBRL Returns dialog. The initial form (OSS-2, PSA, RAL, RCP-UCB)
* is selected based on the menu link the user clicked; user can switch between
@@ -0,0 +1,285 @@
<!--
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/.
-->
<div class="ctr-str-dlg">
<!-- Dialog Header -->
<div class="dlg-header">
<div class="dlg-title-area">
<fa-icon icon="shield-alt" class="header-icon"></fa-icon>
<div>
<h2 class="title">CTR & STR Compliance Control Center</h2>
<p class="subtitle">Anti-Money Laundering (AML) & CFT Regulatory Reporting Terminal</p>
</div>
</div>
<button mat-icon-button [mat-dialog-close]="true" class="close-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Tab Navigators -->
<div class="tab-navs">
<button class="tab-btn" [class.active]="activeTab === 'dashboard'" (click)="activeTab = 'dashboard'">
<fa-icon icon="chart-bar" class="tab-icon"></fa-icon> Compliance Dashboard
</button>
<button class="tab-btn" [class.active]="activeTab === 'guidelines'" (click)="activeTab = 'guidelines'">
<fa-icon icon="university" class="tab-icon"></fa-icon> RBI Regulatory Guidelines
</button>
</div>
<!-- Dialog Body (Tabs) -->
<div class="dlg-body">
<!-- TAB 1: COMPLIANCE DASHBOARD -->
@if (activeTab === 'dashboard') {
<div class="tab-content dashboard-tab">
<!-- SECTION 1: CTR MANAGMENT -->
<div class="dashboard-section card-glass">
<div class="section-hdr">
<div>
<h3>Cash Transaction Reports (CTR - Monthly Cash Registry)</h3>
<p class="section-sub">
Scans and compiles cash transactions exceeding INR 10 Lakhs limit (Single or monthly cumulative cash
deposits/withdrawals).
</p>
</div>
<button class="action-btn primary" [disabled]="isGeneratingCtr" (click)="generateCtrReport()">
@if (isGeneratingCtr) {
<span class="spinner"></span> Generating Report...
} @else {
<fa-icon icon="sync-alt"></fa-icon> Generate Monthly CTR
}
</button>
</div>
<div class="table-actions">
<input
type="text"
placeholder="Search CTR files by month or name..."
[(ngModel)]="ctrFilter"
class="search-input"
/>
</div>
<div class="table-container">
<table class="compliance-table">
<thead>
<tr>
<th>Report Payload Name</th>
<th>Reporting Period</th>
<th>Record Count</th>
<th>Generated By</th>
<th>Generated Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (report of filteredCtrReports; track report.id) {
<tr>
<td class="bold-text"><fa-icon icon="file-code" class="xml-icon"></fa-icon> {{ report.name }}</td>
<td>{{ report.month }}</td>
<td class="center-text">
<span class="cnt-badge">{{ report.recordCount }} txns</span>
</td>
<td>{{ report.generatedBy }}</td>
<td>{{ report.generatedAt | date: 'medium' }}</td>
<td><span class="status-badge ready">Ready</span></td>
<td>
<button
class="icon-action-btn"
matTooltip="Download FIU-IND XML Payload"
(click)="downloadCtrFile(report.name)"
>
<fa-icon icon="download"></fa-icon> Download XML
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="empty-state">No CTR reports found matching your search.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- SECTION 2: STR ALERTS PIPELINE -->
<div class="dashboard-section card-glass mt-20">
<div class="section-hdr">
<div>
<h3>Suspicious Transaction Activity Pipeline (STR Management)</h3>
<p class="section-sub">
Behavioral & structural AML alerts generated by KYC profile rules engine. Investigator actions update
state in real-time.
</p>
</div>
<button class="action-btn secondary" (click)="downloadStrReportTemplate()">
<fa-icon icon="file-excel"></fa-icon> Export Active Alerts (CSV)
</button>
</div>
<div class="table-actions">
<input
type="text"
placeholder="Search alerts by Client, CIF, or alert type..."
[(ngModel)]="strFilter"
class="search-input"
/>
</div>
<div class="alerts-grid">
@for (alert of filteredStrAlerts; track alert.id) {
<div class="alert-card" [class]="alert.status.toLowerCase().replace(' ', '-')">
<div class="card-hdr">
<div class="client-info">
<h4>{{ alert.clientName }}</h4>
<span class="cif-number">{{ alert.clientCif }}</span>
</div>
<span class="status-badge" [class]="alert.status.toLowerCase().replace(' ', '-')">{{
alert.status
}}</span>
</div>
<div class="card-body">
<div class="amount-line">
<span class="label">Amount Highlighted:</span>
<span class="amount">₹{{ alert.amount | number: '1.2-2' }}</span>
</div>
<div class="type-line">
<span class="label">Alert Flag:</span>
<span class="type-value">{{ alert.alertType }}</span>
</div>
<p class="desc">{{ alert.description }}</p>
<span class="date-stamp"
><fa-icon icon="clock"></fa-icon> Flagged Date: {{ alert.date | date: 'mediumDate' }}</span
>
</div>
<div class="card-footer">
@if (alert.status === 'Open') {
<button class="card-btn investigate" (click)="changeAlertStatus(alert.id, 'Investigating')">
<fa-icon icon="search"></fa-icon> Investigate
</button>
}
@if (alert.status === 'Open' || alert.status === 'Investigating') {
<button class="card-btn file-str" (click)="changeAlertStatus(alert.id, 'Reported STR')">
<fa-icon icon="flag"></fa-icon> File STR
</button>
<button class="card-btn close-alert" (click)="changeAlertStatus(alert.id, 'Closed')">
<fa-icon icon="ban"></fa-icon> Dismiss
</button>
}
@if (alert.status === 'Closed' || alert.status === 'Reported STR') {
<button class="card-btn reset-alert" (click)="changeAlertStatus(alert.id, 'Open')">
<fa-icon icon="redo-alt"></fa-icon> Reopen Alert
</button>
}
</div>
</div>
} @empty {
<div class="empty-alerts">No Suspicious Transaction alerts matching your criteria.</div>
}
</div>
</div>
</div>
}
<!-- TAB 2: RBI REGULATORY GUIDELINES -->
@if (activeTab === 'guidelines') {
<div class="tab-content scroll-y">
<div class="info-layout">
<!-- CTR Rules Card -->
<div class="info-card card-glass">
<div class="card-title-badge ctr-badge-color">
<fa-icon icon="coins" class="badge-icon"></fa-icon> CTR - Cash Transaction Report
</div>
<div class="rules-body">
<p class="lead-desc">
As mandated by Section 12 of the **Prevention of Money Laundering Act (PMLA), 2002** and RBI KYC
guidelines, banks must record and report all high-value cash transactions.
</p>
<div class="rule-point">
<h5>1. Transaction Threshold</h5>
<p>
All cash transactions (either deposits or withdrawals) of **value more than INR 10 Lakhs** (or its
equivalent in foreign currency) in a single transaction.
</p>
</div>
<div class="rule-point">
<h5>2. Monthly Aggregation Rule (Structuring check)</h5>
<p>
A series of cash transactions in a calendar month that are **integrally connected** and accumulate to
a total value exceeding **INR 10 Lakhs** must be compiled and reported in the monthly CTR.
</p>
</div>
<div class="rule-point">
<h5>3. Reporting Timeline</h5>
<p>
CTRs must be generated and uploaded to the FIU-IND gateway **by the 15th day of the succeeding month**
(e.g., June transactions must be uploaded by July 15th).
</p>
</div>
<div class="rule-point text-warning-border">
<strong>Important Note:</strong> Transactions where the net cash balance does not change but high volume
is rotated (e.g., cash withdrawals immediately deposited back) must be reported under CTR as separate
debit and credit records.
</div>
</div>
</div>
<!-- STR Rules Card -->
<div class="info-card card-glass">
<div class="card-title-badge str-badge-color">
<fa-icon icon="flag" class="badge-icon"></fa-icon> STR - Suspicious Transaction Report
</div>
<div class="rules-body">
<p class="lead-desc">
Every reporting entity must maintain vigilance over account transactions. If there are reasonable
grounds to suspect transactions involve proceeds of crime, an STR must be generated.
</p>
<div class="rule-point">
<h5>1. What Constitutes Suspicion?</h5>
<p>
Transactions that are complex, show unusual patterns, mismatch the customer’s business/risk profile,
have no apparent economic rationale, or involve suspected proceeds of crime or terror funding.
</p>
</div>
<div class="rule-point">
<h5>2. No Minimum Threshold Limit</h5>
<p>
**There is no minimum monetary limit** for STR. An alert should be generated for any transaction,
regardless of size, if suspicious behavior is flagged.
</p>
</div>
<div class="rule-point">
<h5>3. Reporting Timeline</h5>
<p>
The STR must be filed to FIU-IND **within 7 days** of the Principal Compliance Officer confirming the
suspicion.
</p>
</div>
<div class="rule-point text-danger-border">
<strong>Strict Tipping-off Restriction:</strong> It is illegal to disclose or "tip-off" to the client
that their account is being monitored, investigated, or reported under an STR. Complete confidentiality
must be maintained.
</div>
</div>
</div>
</div>
</div>
}
</div>
</div>
@@ -0,0 +1,750 @@
/**
* 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/.
*/
.ctr-str-dlg {
display: flex;
flex-direction: column;
height: 90vh;
max-height: 820px;
background: #f8fafc;
color: #1e293b;
font-family: Inter, sans-serif;
overflow: hidden;
}
// Header
.dlg-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
.dlg-title-area {
display: flex;
align-items: center;
gap: 14px;
.header-icon {
font-size: 26px;
color: #4f46e5;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.subtitle {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
}
}
.close-btn {
color: #64748b;
&:hover {
color: #0f172a;
}
}
}
// Navigation Tabs
.tab-navs {
display: flex;
gap: 6px;
background: #fff;
padding: 8px 24px;
border-bottom: 1px solid #e2e8f0;
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
.tab-icon {
font-size: 14px;
}
&:hover {
background: #f1f5f9;
color: #0f172a;
}
&.active {
background: #e0e7ff;
color: #4f46e5;
}
}
}
// Scroll layout
.scroll-y {
overflow-y: auto;
flex: 1;
}
// Dialog Body
.dlg-body {
flex: 1;
overflow: hidden;
padding: 24px;
background: #f1f5f9;
display: flex;
flex-direction: column;
}
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
&.scroll-y {
overflow-y: auto;
padding-right: 6px;
/* Custom scrollbar */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
&:hover {
background: #94a3b8;
}
}
}
}
// Dashboard Tab specific layout
.dashboard-tab {
overflow-y: auto;
padding-right: 6px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
// Card Style Container
.card-glass {
background: #fff;
border-radius: 12px;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 5%),
0 2px 4px -1px rgb(0 0 0 / 2%);
border: 1px solid #e2e8f0;
padding: 20px;
}
.dashboard-section {
display: flex;
flex-direction: column;
.section-hdr {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.section-sub {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
}
}
}
.mt-20 {
margin-top: 20px;
}
.mt-25 {
margin-top: 25px;
}
// Search input
.table-actions {
margin-bottom: 14px;
.search-input {
width: 100%;
max-width: 380px;
padding: 8px 12px;
font-size: 13px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #1e293b;
outline: none;
&:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgb(79 70 229 / 10%);
}
}
}
// Button styles
.action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 6px;
padding: 10px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&.primary {
background: #4f46e5;
color: #fff;
&:hover:not([disabled]) {
background: #4338ca;
}
&:disabled {
background: #94a3b8;
cursor: not-allowed;
}
}
&.secondary {
background: #0ea5e9;
color: #fff;
&:hover {
background: #0284c7;
}
}
}
// Spinner animation
.spinner {
width: 12px;
height: 12px;
border: 2px solid rgb(255 255 255 / 30%);
border-radius: 50%;
border-top-color: #fff;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Tables styling
.table-container {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.compliance-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
th {
background: #f8fafc;
color: #475569;
font-weight: 600;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
&.bold-text {
font-weight: 600;
color: #0f172a;
}
&.center-text {
text-align: center;
}
}
tr:last-child td {
border-bottom: none;
}
.cnt-badge {
background: #e2e8f0;
color: #475569;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.xml-icon {
color: #ea580c;
margin-right: 4px;
}
.empty-state {
text-align: center;
color: #64748b;
padding: 24px;
font-style: italic;
}
}
// Icon Action Button
.icon-action-btn {
background: #f1f5f9;
border: 1px solid #cbd5e1;
color: #475569;
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e2e8f0;
color: #0f172a;
}
}
// Alert Cards Grid
.alerts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.alert-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
}
// Left boundary color codes based on status
&.open {
border-left: 4px solid #dc2626;
}
&.investigating {
border-left: 4px solid #ea580c;
}
&.reported-str {
border-left: 4px solid #16a34a;
}
&.closed {
border-left: 4px solid #64748b;
}
.card-hdr {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.cif-number {
font-size: 11px;
color: #64748b;
}
}
.card-body {
flex: 1;
font-size: 12px;
color: #475569;
.amount-line,
.type-line {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
.label {
color: #64748b;
}
.amount {
font-weight: 700;
color: #0f172a;
}
.type-value {
font-weight: 600;
color: #4f46e5;
}
}
.desc {
margin: 8px 0;
line-height: 1.4;
background: #f8fafc;
padding: 8px;
border-radius: 4px;
border: 1px dashed #e2e8f0;
font-style: italic;
}
.date-stamp {
font-size: 11px;
color: #94a3b8;
display: block;
margin-top: 8px;
}
}
.card-footer {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
border-top: 1px solid #f1f5f9;
padding-top: 12px;
.card-btn {
flex: 1;
min-width: 75px;
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s ease;
&.investigate {
background: #fef3c7;
color: #d97706;
&:hover {
background: #fde68a;
}
}
&.file-str {
background: #dcfce7;
color: #16a34a;
&:hover {
background: #bbf7d0;
}
}
&.close-alert {
background: #fee2e2;
color: #dc2626;
&:hover {
background: #fecaca;
}
}
&.reset-alert {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
&:hover {
background: #e2e8f0;
}
}
}
}
}
// Badges
.status-badge {
padding: 3px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
&.ready {
background: #dcfce7;
color: #16a34a;
}
&.open {
background: #fee2e2;
color: #dc2626;
}
&.investigating {
background: #fef3c7;
color: #d97706;
}
&.reported-str {
background: #dbeafe;
color: #2563eb;
}
&.closed {
background: #f1f5f9;
color: #475569;
}
}
.empty-alerts {
grid-column: 1 / -1;
text-align: center;
padding: 36px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #64748b;
font-style: italic;
font-size: 13px;
}
// Tab 2: Guidelines layout
.info-layout {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
@media (width >= 900px) {
grid-template-columns: 1fr 1fr;
}
}
.info-card {
display: flex;
flex-direction: column;
.card-title-badge {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
margin-bottom: 16px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #fff;
}
.ctr-badge-color {
background: #ea580c;
}
.str-badge-color {
background: #be185d;
}
.rules-body {
font-size: 13px;
line-height: 1.5;
color: #334155;
.lead-desc {
font-size: 14px;
color: #0f172a;
margin-bottom: 20px;
}
.rule-point {
margin-bottom: 18px;
h5 {
margin: 0 0 6px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
p {
margin: 0;
color: #475569;
}
}
.text-warning-border {
border-left: 3px solid #eab308;
background: #fef9c3;
padding: 10px 14px;
border-radius: 0 6px 6px 0;
color: #854d0e;
}
.text-danger-border {
border-left: 3px solid #ef4444;
background: #fee2e2;
padding: 10px 14px;
border-radius: 0 6px 6px 0;
color: #991b1b;
}
}
}
// Tab 3: Architecture & Schema
.arch-container {
display: flex;
flex-direction: column;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.desc {
margin: 4px 0 20px;
font-size: 12px;
color: #64748b;
}
}
.arch-section {
h4 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.section-sub {
margin: 0 0 10px;
font-size: 12px;
color: #64748b;
}
}
.code-snippet {
background: #0f172a;
color: #e2e8f0;
padding: 14px;
border-radius: 8px;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
overflow-x: auto;
line-height: 1.4;
margin: 0;
&.plain {
background: #1e293b;
color: #94a3b8;
}
}
// API Routes
.route-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
font-size: 12px;
.method {
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: #fff;
min-width: 45px;
text-align: center;
&.get {
background: #16a34a;
}
&.put {
background: #ea580c;
}
&.post {
background: #2563eb;
}
}
.path {
font-family: 'Courier New', Courier, monospace;
font-weight: 700;
color: #0f172a;
}
.route-desc {
color: #475569;
flex: 1;
}
}
@@ -0,0 +1,279 @@
/**
* 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 { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { FaIconComponent, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import {
faShieldAlt,
faChartBar,
faUniversity,
faCode,
faSyncAlt,
faFileExcel,
faFileCode,
faDownload,
faClock,
faSearch,
faFlag,
faBan,
faRedoAlt,
faCoins
} from '@fortawesome/free-solid-svg-icons';
interface CtrReport {
id: number;
name: string;
month: string;
recordCount: number;
generatedBy: string;
generatedAt: Date;
status: 'Ready' | 'Generating';
}
interface StrAlert {
id: number;
clientName: string;
clientCif: string;
alertType: string;
amount: number;
status: 'Open' | 'Investigating' | 'Reported STR' | 'Closed';
description: string;
date: Date;
}
@Component({
selector: 'mifosx-ctr-str-dialog',
standalone: true,
templateUrl: './ctr-str-dialog.component.html',
styleUrls: ['./ctr-str-dialog.component.scss'],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatTabsModule,
MatTooltipModule,
MatIconModule,
FaIconComponent
]
})
export class CtrStrDialogComponent {
dialogRef = inject(MatDialogRef<CtrStrDialogComponent>);
constructor() {
const library = inject(FaIconLibrary);
library.addIcons(
faShieldAlt,
faChartBar,
faUniversity,
faCode,
faSyncAlt,
faFileExcel,
faFileCode,
faDownload,
faClock,
faSearch,
faFlag,
faBan,
faRedoAlt,
faCoins
);
}
activeTab: 'dashboard' | 'guidelines' = 'dashboard';
// Mock State
isGeneratingCtr = false;
ctrFilter = '';
strFilter = '';
ctrReports: CtrReport[] = [
{
id: 1,
name: 'CTR_REPORT_JUNE_2026.xml',
month: 'June 2026',
recordCount: 14,
generatedBy: 'Compliance_Officer_1',
generatedAt: new Date(2026, 6, 5, 10, 30),
status: 'Ready'
},
{
id: 2,
name: 'CTR_REPORT_MAY_2026.xml',
month: 'May 2026',
recordCount: 22,
generatedBy: 'Compliance_Officer_1',
generatedAt: new Date(2026, 5, 4, 14, 15),
status: 'Ready'
},
{
id: 3,
name: 'CTR_REPORT_APRIL_2026.xml',
month: 'April 2026',
recordCount: 9,
generatedBy: 'Compliance_Officer_2',
generatedAt: new Date(2026, 4, 3, 11, 45),
status: 'Ready'
}
];
strAlerts: StrAlert[] = [
{
id: 101,
clientName: 'Aarav Sharma',
clientCif: 'CIF982348',
alertType: 'Profile Mismatch',
amount: 450000,
status: 'Open',
description:
'Student account received a large inward transfer (INR 4.5L) mismatching declared zero/student income category.',
date: new Date(2026, 6, 26)
},
{
id: 102,
clientName: 'Karan Malhotra',
clientCif: 'CIF229482',
alertType: 'Structuring (Smurfing)',
amount: 980000,
status: 'Investigating',
description:
'Multiple cash deposits of INR 2.45L, 2.45L, 2.45L, 2.45L within 10 days, totaling INR 9.8L cash (deliberately staying below the 10L single cash transaction CTR threshold).',
date: new Date(2026, 6, 24)
},
{
id: 103,
clientName: 'Nexora Real Estate Ltd',
clientCif: 'CIF500481',
alertType: 'Turnover Outlier',
amount: 15000000,
status: 'Open',
description:
'Entity account registered an outward wire of INR 1.5Cr when maximum monthly turnover limit is set to INR 50L.',
date: new Date(2026, 6, 20)
},
{
id: 104,
clientName: 'Priyanka Patel',
clientCif: 'CIF774920',
alertType: 'Dormant Account Reactivation',
amount: 600000,
status: 'Reported STR',
description:
'Account inactive for 18 months suddenly received a cash deposit of INR 6L, followed by immediate online transfers.',
date: new Date(2026, 6, 15)
}
];
get filteredCtrReports() {
return this.ctrReports.filter(
(r) =>
r.name.toLowerCase().includes(this.ctrFilter.toLowerCase()) ||
r.month.toLowerCase().includes(this.ctrFilter.toLowerCase())
);
}
get filteredStrAlerts() {
return this.strAlerts.filter(
(a) =>
a.clientName.toLowerCase().includes(this.strFilter.toLowerCase()) ||
a.clientCif.toLowerCase().includes(this.strFilter.toLowerCase()) ||
a.alertType.toLowerCase().includes(this.strFilter.toLowerCase())
);
}
// Actions
generateCtrReport() {
if (this.isGeneratingCtr) {
return;
}
this.isGeneratingCtr = true;
// Simulate API call to Spring Boot batch job
setTimeout(() => {
const date = new Date();
const newReport: CtrReport = {
id: this.ctrReports.length + 1,
name: `CTR_REPORT_JULY_2026_DRAFT.xml`,
month: 'July 2026 (Draft)',
recordCount: 8,
generatedBy: 'Current_Admin',
generatedAt: date,
status: 'Ready'
};
this.ctrReports.unshift(newReport);
this.isGeneratingCtr = false;
}, 2000);
}
downloadCtrFile(reportName: string) {
const dummyXml = `<?xml version="1.0" encoding="UTF-8"?>
<FIU-IND-CTR-Report xmlns="http://fiuindia.gov.in/ctr/v1">
<Header>
<ReportType>CTR</ReportType>
<ReportingEntityName>CBS Nexora Bank</ReportingEntityName>
<ReportingPeriod>2026-06</ReportingPeriod>
</Header>
<ReportData>
<Record>
<TransactionID>TXN88924824</TransactionID>
<Date>2026-06-15</Date>
<Mode>CASH</Mode>
<Amount>1250000.00</Amount>
<AccountType>SAVINGS</AccountType>
<CustomerName>Aarav Sharma</CustomerName>
<CifNumber>CIF982348</CifNumber>
</Record>
</ReportData>
</FIU-IND-CTR-Report>`;
const blob = new Blob([dummyXml], { type: 'application/xml' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = reportName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
changeAlertStatus(alertId: number, newStatus: 'Open' | 'Investigating' | 'Reported STR' | 'Closed') {
const alert = this.strAlerts.find((a) => a.id === alertId);
if (alert) {
alert.status = newStatus;
}
}
downloadStrReportTemplate() {
const dummyCsv =
`AlertID,ClientName,CIF,AlertType,Amount,Status,Description\n` +
this.strAlerts
.map(
(a) =>
`"${a.id}","${a.clientName}","${a.clientCif}","${a.alertType}","${a.amount}","${a.status}","${a.description.replace(/"/g, '""')}"`
)
.join('\n');
const blob = new Blob([dummyCsv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `STR_AML_ALERTS_EXPORT.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260728',
'hash': '2e4187c3'
'version': '260729',
'hash': 'c1fc95d9'
},
'allow_switching_backend_instance': true
};