From f9b73a4930aa7effdeac88aa5a152df7b1df676e Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 18 Sep 2026 10:32:47 +0530 Subject: [PATCH] feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report --- ...te-financial-activity-mapping.component.ts | 21 +- ...it-financial-activity-mapping.component.ts | 21 +- .../financial-activity-account-options.ts | 38 ++ src/app/fiu/fiu.component.html | 139 +++---- src/app/fiu/fiu.component.ts | 82 ++-- .../aml-bulk-review-dialog.component.ts | 8 +- .../aml-flagged-transactions.component.html | 173 ++++++-- .../aml-flagged-transactions.component.scss | 109 +++++ .../aml-flagged-transactions.component.ts | 384 +++++++++++++++++- .../aml-review-alert-dialog.component.ts | 7 +- .../risk-change-approvals.component.ts | 19 +- .../risk-review-report.component.ts | 29 +- src/app/risk-review/risk-review.compute.ts | 63 ++- src/environments/.env.ts | 4 +- 14 files changed, 863 insertions(+), 234 deletions(-) create mode 100644 src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts diff --git a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts index 2aa1f0481..379940db2 100644 --- a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts +++ b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts @@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service'; import { GLAccount } from 'app/shared/models/general.model'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { accountOptionsForActivity } from '../financial-activity-account-options'; /** * Create financial activity mapping component. @@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit { */ setGlAccountData() { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { - switch (financialActivityId) { - case 100: - case 101: - case 102: - case 103: - this.glAccountData = this.glAccountOptions.assetAccountOptions; - break; - case 200: - case 201: - this.glAccountData = this.glAccountOptions.liabilityAccountOptions; - break; - case 300: - this.glAccountData = this.glAccountOptions.equityAccountOptions; - break; - } + this.glAccountData = accountOptionsForActivity( + this.financialActivityData, + financialActivityId, + this.glAccountOptions + ); }); } diff --git a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts index db1f71ca2..b8c6160b7 100644 --- a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts +++ b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts @@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service'; import { GLAccount } from 'app/shared/models/general.model'; import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { accountOptionsForActivity } from '../financial-activity-account-options'; /** * Edit financial activity mapping component. @@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit { */ setGLAccountData() { this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => { - switch (financialActivityId) { - case 100: - case 101: - case 102: - case 103: - this.glAccountData = this.glAccountOptions.assetAccountOptions; - break; - case 200: - case 201: - this.glAccountData = this.glAccountOptions.liabilityAccountOptions; - break; - case 300: - this.glAccountData = this.glAccountOptions.equityAccountOptions; - break; - } + this.glAccountData = accountOptionsForActivity( + this.financialActivityData, + financialActivityId, + this.glAccountOptions + ); }); } diff --git a/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts new file mode 100644 index 000000000..57a076f89 --- /dev/null +++ b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts @@ -0,0 +1,38 @@ +/** + * 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/. + */ + +/** + * The GL accounts a financial activity may be mapped to. + * + * The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every + * option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on + * the id instead, listing the ones they knew: + * + * case 100, 101, 102, 103 → asset + * case 200, 201 → liability + * case 300 → equity + * + * Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid + * (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became + * unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for + * each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out + * of step with the backend. + * + * @param activities the template's financialActivityOptions + * @param activityId the activity the user picked + * @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / … + */ +export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] { + const activity = (activities || []).find((a) => a?.id === activityId); + const accountType = activity?.mappedGLAccountType; + if (!accountType || !glAccountOptions) { + return []; + } + // ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE. + return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? []; +} diff --git a/src/app/fiu/fiu.component.html b/src/app/fiu/fiu.component.html index d2f31d2ec..3c4946314 100644 --- a/src/app/fiu/fiu.component.html +++ b/src/app/fiu/fiu.component.html @@ -167,7 +167,9 @@ -
Due: {{ ctrDueDateLabel }}
+
+ Due: {{ ctrDueDateLabel }} +
@@ -220,9 +222,7 @@ @if (ctrLinesLoading) {
Loading the generated CTR lines…
} @else if (ctrLinesError) { -
- {{ ctrLinesError }} -
+
{{ ctrLinesError }}
} @else if (!ctrTxns.length) {
@@ -432,87 +432,17 @@ }
- -
-
-

Live AML Alerts (Awaiting Triage)

-

Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate

-
- - + +
+ + + Alerts are triaged in RBI › AML Rules Master › Flagged Transactions. What an + officer escalates there arrives here as an STR case. +
-
- - - - - - - - - - - - - - - - - @for (alert of amlSuspiciousAlerts; track alert.id) { - - - - - - - - - - - - - } @empty { - - - - } - -
IDClientTypeRuleAmountRaised OnStatusRoutes ToNotesAction
{{ alert.id }}{{ alert.clientDisplayName }}{{ alert.transactionType }}{{ alert.ruleCode }} — {{ alert.ruleDescription }}₹{{ alert.amount | number: '1.0-0' }}{{ asDate(alert.raisedOn) | date: 'medium' }} - {{ alert.status }} - - {{ alertRouteLabel(alert.status, false) }} - - @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { - - } @else { - {{ alert.reviewNotes }} - } - - @if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') { -
- - -
- } -
No suspicious behavioral alerts awaiting triage.
-
-

- Pending awaiting Whitelist/Escalate decision · - → STR escalated to Principal Officer as an STR case -

- -
@@ -590,7 +520,7 @@ - @for (sc of amlStrCases; track sc.id) { + @for (sc of liveStrCases; track sc.id) { {{ sc.id }} {{ sc.clientDisplayName }} @@ -607,13 +537,50 @@ } @empty { - No STR cases yet. + No STR cases outstanding. }
+ + @if (filedStrCases.length) { +
+
+

Filed with FIU-IND

+

Reported and closed — kept for the record, with the reference the filing was made under.

+
+
+
+ + + + + + + + + + + + + @for (sc of filedStrCases; track sc.id) { + + + + + + + + + } + +
IDClientConclusionFiled OnFIU ReferenceAction
{{ sc.id }}{{ sc.clientDisplayName }}{{ sc.conclusion || '—' }}{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}{{ sc.strReferenceNo || '—' }}
+
+ } + @if (selectedStrCase) {
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index 7e45eb931..245162f51 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -220,13 +220,29 @@ export class FiuComponent implements OnInit { */ sanctionsStrCases: any[] = []; sanctionsStrFilter = ''; - amlAlertStatusFilter = ''; - amlReviewNotes: Record = {}; amlCtrReports: any[] = []; ctrGenerateMonth = ''; amlStrCases: any[] = []; + + /** + * The cases still carrying work, which is what "Live" means here. + * + *

+ * A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there + * is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being + * one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from + * the closed by reading a status column. Filed cases are listed below, where they can still be looked up. + */ + get liveStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status !== 'FILED'); + } + + /** Cases already filed with FIU-IND, kept for the record rather than as work. */ + get filedStrCases(): any[] { + return this.amlStrCases.filter((c) => c?.status === 'FILED'); + } selectedStrCase: any = null; concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS'; concludeInvestigationNotes = ''; @@ -633,10 +649,9 @@ export class FiuComponent implements OnInit { } /** - * Always fetches every status. The triage table and the cleared register are two views of the - * same set, so filtering server-side would empty one whenever the other was narrowed — and the - * CTR tab reads this list too, which a status filter chosen on the STR tab should not touch. - * amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead. + * Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of + * the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty + * another. */ loadAmlAlerts(): void { this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || [])); @@ -679,7 +694,8 @@ export class FiuComponent implements OnInit { this.ctrLinesLoading = false; this.ctrTxns = []; this.ctrLinesError = - err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.'; + err?.error?.defaultUserMessage || + 'Could not load the generated CTR lines. Generate a report for the month first.'; } }); } @@ -779,48 +795,6 @@ export class FiuComponent implements OnInit { return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); } - /** - * Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of - * why a flagged transaction was disposed of, and an empty string leaves no trace of the decision. - */ - private reviewNotesOf(alert: any): string | null { - const notes = (this.amlReviewNotes[alert.id] || '').trim(); - if (!notes) { - this.toast(`Enter review notes before disposing of alert #${alert.id}`); - return null; - } - return notes; - } - - whitelistAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { - return; - } - this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} whitelisted — no STR raised`); - this.loadAmlAlerts(); - }, - error: this.apiError(`Whitelisting alert #${alert.id}`) - }); - } - - escalateAlert(alert: any): void { - const notes = this.reviewNotesOf(alert); - if (!notes) { - return; - } - this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({ - next: () => { - this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`); - this.loadAmlAlerts(); - this.loadAmlStrCases(); - }, - error: this.apiError(`Escalating alert #${alert.id}`) - }); - } - generateCtrReport(): void { if (!this.ctrGenerateMonth) { this.toast('Pick a reporting month first'); @@ -1039,12 +1013,6 @@ export class FiuComponent implements OnInit { * The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed * with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}. */ - get amlSuspiciousAlerts(): any[] { - return this.behaviouralAlerts.filter( - (a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter) - ); - } - /** Cleared register: alerts closed as false positives, kept for audit with their evidence. */ get amlWhitelistedAlerts(): any[] { return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED'); @@ -1707,7 +1675,9 @@ export class FiuComponent implements OnInit { private writeLiveStrXml(cases: any[], filename: string): void { const xml: string[] = []; xml.push(''); - xml.push(``); + xml.push( + `` + ); xml.push(` `); if (this.principalOfficerName) { xml.push(` `); diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts index 02a8097d4..e49fa73b5 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-bulk-review-dialog.component.ts @@ -57,8 +57,8 @@ export interface AmlBulkReviewData {

- The same decision, reason and evidence are recorded against each alert below. Every one stays - individually auditable. + The same decision, reason and evidence are recorded against each alert below. Every one stays individually + auditable.

@@ -218,7 +218,9 @@ export class AmlBulkReviewDialogComponent { submitting = false; errorMessage = ''; - constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) { + data: AmlBulkReviewData = inject(MAT_DIALOG_DATA); + + constructor() { this.form = this.fb.group({ evidence: this.fb.array(this.evidenceTypes.map(() => false)), evidenceRef: [ diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html index d566f2142..ab2aaf069 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -36,6 +36,17 @@ + +
@@ -44,6 +55,52 @@ Could not reach the alert feed. Retrying automatically.
+ +
+ Period + @for (m of periodModes; track m.key) { + + } + + + @if (periodMode === 'day') { + + } + @if (periodMode === 'week') { + + } + @if (periodMode === 'month') { + + } + @if (periodMode === 'year') { + + } + @if (periodLabel) { + {{ periodLabel }} + } +
+
Status @@ -60,6 +117,34 @@
+ +
+ Top rules + @for (n of topRuleOptions; track n) { + + } + @if (topRules !== null) { + {{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view + } +
+ + +@if (ruleFrequency.length > 1) { +
+ @for (r of ruleFrequency; track r.code) { + + {{ r.code }} + {{ r.count }} + + } +
+} +
@@ -103,7 +188,9 @@ @if (selectedCount > 0) {
{{ selectedCount }} selected - Low / medium only — a transaction that tripped a high-severity rule is decided on its own. + Low / medium only — a transaction that tripped a high-severity rule is decided on its own. -
-
- - {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} - - - - description - - - → STR - -
+ + @if (heldByAnother(alert)) { +
+ + lock + + with {{ heldBy(alert) }} +
+ } @else { +
+ + {{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }} + + + + description + + + → STR + +
+ }
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss index 2070f6a36..a11f5bc7c 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -902,3 +902,112 @@ cursor: help; user-select: none; } + +/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read + as one control strip rather than a form field dropped above a set of buttons. */ +.day-input { + padding: 5px 11px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font-family: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover { + border-color: #1a237e; + } + + &:focus { + outline: none; + border-color: #1a237e; + } +} + +/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer + has no way to tell which Monday-to-Sunday span they are looking at. */ +.period-resolved { + font-size: 12px; + color: #64748b; + font-variant-numeric: tabular-nums; +} + +/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather + than changing what is on screen. */ +.export-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 13px; + border: 1px solid #bbf7d0; + border-radius: 999px; + background: #f0fdf4; + color: #15803d; + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover:not(:disabled) { + background: #dcfce7; + border-color: #86efac; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */ +.held-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #94a3b8; + cursor: help; +} + +.held-by { + color: #64748b; + font-size: 12px; + font-style: italic; +} + +/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth + filter competing with the three above it. */ +.rule-freq { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 0 14px; +} + +.rule-freq-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #f8fafc; + font-size: 11px; + font-weight: 600; + color: #475569; +} + +.rule-freq-n { + padding: 0 6px; + border-radius: 999px; + background: #e2e8f0; + color: #1e293b; + font-variant-numeric: tabular-nums; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index df87f805a..6f5b070ea 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -17,15 +17,21 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { Subject, timer, of } from 'rxjs'; import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators'; /** Custom Services */ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; +import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component'; import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component'; +import { exportWorkbook } from '../../../cashier/cashier-export'; + +/** How the feed is narrowed by time. */ +type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year'; /** * Live feed of transactions the AML rule engine has flagged. @@ -43,6 +49,7 @@ import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component standalone: true, imports: [ CommonModule, + FormsModule, MatTableModule, MatPaginatorModule, MatIconModule, @@ -58,6 +65,10 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O private amlService = inject(AmlService); private dialog = inject(MatDialog); private alertService = inject(AlertService); + private auth = inject(AuthenticationService); + + /** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */ + private readonly currentUsername = this.auth.getCredentials()?.username ?? ''; private destroy$ = new Subject(); /** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */ @@ -146,7 +157,15 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O /** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */ expandedId: number | null = null; - + /** + * Opens or closes the detail pane. Nothing is recorded by reading. + * + *

+ * Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work + * starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes + * on a single click describes the click rather than any review. The queue filled with alerts nobody was working, + * and the one state the flag existed to make visible — this one is actually being worked — became meaningless. + */ toggleExpanded(alert: any): void { this.expandedId = this.expandedId === alert.id ? null : alert.id; // Ticks belong to the alert they were made on, not to whichever pane is open next. @@ -164,7 +183,9 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried * it, so an older row still expands to something rather than appearing to have matched nothing. */ - matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] { + matchedRules( + alert: any + ): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] { if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) { return alert.matchedRules; } @@ -358,13 +379,226 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O readonly statusFilters: { key: string; label: string }[] = [ { key: '', label: 'All alerts' }, { key: 'RAISED', label: 'Raised' }, - { key: 'UNDER_REVIEW', label: 'Under review' }, + // No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the + // chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and + // canReview still treats them as open — but it is not offered as a filter nobody can populate. { key: 'ESCALATED', label: 'Escalated' }, { key: 'WHITELISTED', label: 'Whitelisted' } ]; activeStatus = ''; private statusCounts: Record = {}; + /** + * The period being worked. + * + * A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide + * today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work + * — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today". + */ + readonly periodModes: { key: PeriodMode; label: string }[] = [ + { key: 'all', label: 'All time' }, + { key: 'day', label: 'Day wise' }, + { key: 'week', label: 'Week wise' }, + { key: 'month', label: 'Month wise' }, + { key: 'year', label: 'Year wise' } + ]; + periodMode: PeriodMode = 'all'; + + /** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */ + filterDay = ''; + filterWeek = ''; + filterMonth = ''; + filterYear = ''; + + /** + * Narrows the table to the rules firing most often in the chosen period. + * + *

+ * A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real + * pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of + * the count are the ones worth an officer's attention first, and the long tail below them can wait. + * + *

+ * Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing. + */ + readonly topRuleOptions: (number | null)[] = [ + null, + 10, + 20, + 50 + ]; + topRules: number | null = null; + + setTopRules(n: number | null): void { + this.topRules = n; + this.applyStatusFilter(true); + } + + /** + * The rule codes to keep, most-triggered first, or null when every rule is in view. + * + *

+ * Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means + * the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time + * instead of shifting between polls. + */ + private topRuleCodes(pool: any[]): Set | null { + if (this.topRules === null) { + return null; + } + const counts = new Map(); + for (const a of pool) { + const code = a?.ruleCode; + if (code) { + counts.set(code, (counts.get(code) ?? 0) + 1); + } + } + if (counts.size <= this.topRules) { + return null; + } + const ranked = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, this.topRules) + .map(([code]) => code); + return new Set(ranked); + } + + /** How many distinct rules the current view holds, so the chip can say what it is narrowing. */ + get distinctRuleCount(): number { + return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size; + } + + /** + * The rules in view with how often each fired, busiest first. + * + *

+ * The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer. + * Without it "Top 10" narrows the table and leaves the officer to work out what it kept. + */ + get ruleFrequency(): { code: string; count: number }[] { + const counts = new Map(); + for (const a of this.alerts.data) { + if (a?.ruleCode) { + counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1); + } + } + return Array.from(counts.entries()) + .map( + ([ + code, + count + ]) => ({ code, count }) + ) + .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code)); + } + + setPeriodMode(mode: PeriodMode): void { + this.periodMode = mode; + // Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an + // empty field the officer has to fill before anything happens. + const now = new Date(); + const iso = this.isoOf(now); + if (mode === 'day' && !this.filterDay) { + this.filterDay = iso; + } + if (mode === 'week' && !this.filterWeek) { + this.filterWeek = iso; + } + if (mode === 'month' && !this.filterMonth) { + this.filterMonth = iso.slice(0, 7); + } + if (mode === 'year' && !this.filterYear) { + this.filterYear = String(now.getFullYear()); + } + this.applyStatusFilter(true); + } + + onPeriodChange(): void { + this.applyStatusFilter(true); + } + + /** The years the feed actually covers, newest first, so the picker offers nothing empty. */ + get availableYears(): string[] { + const years = new Set(); + for (const a of this.allAlerts) { + if (a.raisedOnDate) { + years.add(String(a.raisedOnDate.getFullYear())); + } + } + const list = Array.from(years).sort().reverse(); + return list.length ? list : [String(new Date().getFullYear())]; + } + + /** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */ + get periodLabel(): string { + const range = this.periodRange(); + if (!range) { + return ''; + } + return range.from === range.to ? range.from : `${range.from} to ${range.to}`; + } + + private isoOf(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + /** + * The chosen period as an inclusive from/to, or null for all time. + * + * Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in + * the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it. + */ + private periodRange(): { from: string; to: string } | null { + switch (this.periodMode) { + case 'day': + return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null; + case 'week': { + if (!this.filterWeek) { + return null; + } + const [ + y, + m, + d + ] = this.filterWeek.split('-').map(Number); + const picked = new Date(y, m - 1, d); + const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1; + const monday = new Date(y, m - 1, d - backToMonday); + const sunday = new Date(y, m - 1, d - backToMonday + 6); + return { from: this.isoOf(monday), to: this.isoOf(sunday) }; + } + case 'month': { + if (!this.filterMonth) { + return null; + } + const [ + y, + m + ] = this.filterMonth.split('-').map(Number); + // Day 0 of the next month is the last day of this one, which handles February and leap years without a table. + return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) }; + } + case 'year': + return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null; + default: + return null; + } + } + + /** Whether an alert was raised inside the chosen period. */ + private inActivePeriod(alert: any): boolean { + const range = this.periodRange(); + if (!range) { + return true; + } + const d: Date | null = alert?.raisedOnDate; + if (!d) { + return false; + } + const iso = this.isoOf(d); + return iso >= range.from && iso <= range.to; + } + /** * The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or * null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for @@ -530,11 +764,16 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * it — doing that on the first pass left pageSize at 0 and emptied the table. */ private applyStatusFilter(resetPage = false): void { - const byStatus = this.activeStatus - ? this.allAlerts.filter((a) => a.status === this.activeStatus) - : this.allAlerts; - this.alerts.data = + // The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and + // an officer who has picked a day expects their counts to describe that day rather than the whole feed. + const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a)); + const byStatus = this.activeStatus ? byDay.filter((a) => a.status === this.activeStatus) : byDay; + const bySeverity = this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity); + // Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not + // the ten busiest overall then filtered down to a handful. + const keep = this.topRuleCodes(bySeverity); + this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity; if (!this.alerts.paginator && this.paginator) { this.alerts.paginator = this.paginator; } @@ -573,15 +812,119 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * add up to the total while one of them is narrowing the table, and the total reads as the way * back to all of them. */ + exporting = false; + + /** + * Exports exactly what the table is showing. + * + *

+ * Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are + * the period, status and severity in the file. A workbook that quietly held more than the screen did would be + * worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it + * on has no way to tell. + * + *

+ * The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts" + * with nothing about which month it covers cannot be checked later by anyone, including the person who made it. + */ + async exportExcel(): Promise { + const rows = this.alerts.data; + if (!rows.length || this.exporting) { + return; + } + this.exporting = true; + try { + const scope = [ + this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time', + `Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`, + `Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`, + `Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}` + ].join(' · '); + + await exportWorkbook( + [ + { + sheetName: 'Flagged Transactions', + title: 'AML — Flagged Transactions', + subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`, + headers: [ + 'Alert ID', + 'Raised On', + 'Customer', + 'Customer ID', + 'Transaction', + 'Txn ID', + 'Governing Rule', + 'Rule Description', + 'All Rules Matched', + 'Severity', + 'Amount', + 'Status', + 'Reviewed By', + 'Review Notes' + ], + rows: rows.map((a) => [ + a.id ?? null, + a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '', + a.clientDisplayName || '', + a.clientId ?? null, + a.transactionType || '', + a.transactionId ?? null, + a.ruleCode || '', + a.ruleDescription || '', + // Every rule the transaction tripped, not only the one it reports under: the governing rule alone can + // hide a high-severity finding that a reader of the sheet would need. + this.matchedRules(a) + .map((r) => r.ruleCode) + .join(', '), + this.severityLabel(this.severityBand(a)), + Number(a.amount) || 0, + a.status || '', + a.reviewedByUsername || '', + a.reviewNotes || '' + ]), + widths: [ + 9, + 20, + 24, + 12, + 12, + 10, + 30, + 38, + 44, + 11, + 15, + 14, + 14, + 50 + ] + } + ], + `AML-Flagged-${this.periodLabel || 'all-time'}` + ); + } finally { + this.exporting = false; + } + } + + private statusLabel(key: string): string { + return this.statusFilters.find((s) => s.key === key)?.label ?? key; + } + get summaryTotal(): number { - return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length; + const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); + return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length; } private recomputeStatusCounts(): void { - this.statusCounts = { '': this.allAlerts.length }; + // Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed. + // A chip whose figure describes a different set from the table under it is worse than no figure. + const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); + this.statusCounts = { '': pool.length }; for (const s of this.statusFilters) { if (s.key) { - this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length; + this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length; } } } @@ -633,7 +976,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O * opening a second STR case — this gate is what prevents it. */ canReview(alert: any): boolean { - return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW'; + if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') { + return false; + } + // An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it + // anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close. + return !this.heldByAnother(alert); + } + + /** True when this alert is under review by somebody other than the signed-in officer. */ + heldByAnother(alert: any): boolean { + return ( + alert?.status === 'UNDER_REVIEW' && + !!alert?.reviewedByUsername && + alert.reviewedByUsername !== this.currentUsername + ); + } + + /** Who has the alert, for the message shown in place of the action buttons. */ + heldBy(alert: any): string { + return alert?.reviewedByUsername || 'another officer'; } review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void { diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts index 2d6825d64..beddd4527 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts @@ -196,7 +196,12 @@ export class AmlReviewAlertDialogComponent { // Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence // requirements are the same either way, which is why this dialog serves both. const request = this.data.ruleCodes?.length - ? this.amlService.reviewAlertRules(this.data.alert.id, this.data.ruleCodes, this.data.disposition, this.composedNotes) + ? this.amlService.reviewAlertRules( + this.data.alert.id, + this.data.ruleCodes, + this.data.disposition, + this.composedNotes + ) : this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes); request.subscribe({ next: () => { diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts index 51a3e1798..677928e01 100644 --- a/src/app/risk-review/risk-change-approvals.component.ts +++ b/src/app/risk-review/risk-change-approvals.component.ts @@ -92,7 +92,9 @@ const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High {{ r.reason }} - {{ r.status }} + {{ + r.status + }} {{ r.makerUsername }} {{ r.createdDate }} @@ -250,7 +252,12 @@ export class RiskChangeApprovalsComponent implements OnInit { private snackBar = inject(MatSnackBar); rows: any[] = []; - readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', '']; + readonly statuses = [ + 'PENDING', + 'APPROVED', + 'REJECTED', + '' + ]; selectedStatus = 'PENDING'; ngOnInit(): void { @@ -363,11 +370,15 @@ export class RejectRiskChangeDialogComponent { private snackBar = inject(MatSnackBar); dialogRef = inject(MatDialogRef); + data: { request: any } = inject(MAT_DIALOG_DATA); form: FormGroup; busy = false; - constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { - this.form = this.fb.group({ rejectionReason: ['', Validators.required] }); + constructor() { + this.form = this.fb.group({ rejectionReason: [ + '', + Validators.required + ] }); } submit(): void { diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 3b4fd2fb4..ee9d5f2b9 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -264,7 +264,12 @@ export class RiskReviewReportComponent implements OnInit { ) ) ).subscribe((results) => { - const byClient = new Map(results.map((r) => [r.id, r])); + const byClient = new Map( + results.map((r) => [ + r.id, + r + ]) + ); this.liveAlerts = rows.map((r) => { const info = byClient.get(r.clientId); return { @@ -470,24 +475,24 @@ export class RiskReviewReportComponent implements OnInit { ] }, { - // Its own section, and above "no change needed", because it is the opposite of that. An unrated customer - // whose money is moving has no rating for the behaviour to be compared against, so nothing here can say - // whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the - // one action the report was asking for. + // Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose + // money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it + // is in keeping - the work is to give them one, and grouping them with the customers nothing need be done + // about buried the one action the report was asking for. key: 'unrated', - title: 'Rating needed', + title: 'Update risk', hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.', tone: 'up', rows: rest.filter((c) => !c.currentRisk) }, { - // Everything an alert put on the report that the policy will not move. "No change needed" read as though - // these customers were fine, which is not what it means: a High-rated customer with four open alerts is on - // this list, and the reason is that the conditions for lowering the rating are not met - not that there is - // nothing to look at. The reason on each row says which condition fell short. + // Everything an alert put on the report that the policy will not move yet. "No change needed" read as though + // these customers were fine, which is not what it means: a customer with four open alerts sits here, and the + // reason is that the period does not yet carry enough to act on - not that there is nothing to look at. + // "Watching" says what the section is for, and the reason on each row says which condition fell short. key: 'same', - title: 'Rating holds — no change this period', - hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.', + title: 'Watching — not enough to act on', + hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.', tone: 'hold', rows: rest.filter((c) => c.currentRisk && c.direction === 'same') } diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index 558b80ab5..215fcac21 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same'; const RANK_TO_LEVEL: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; +/** + * What has to have been seen before this screen's own reckoning will argue for lowering a rating. + * + * The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts, + * and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest, + * because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on + * amounts alone let three small deposits in one month argue for taking a customer off Medium. + */ +const MIN_ALERTS_TO_LOWER = 5; +const MIN_MONTHS_TO_LOWER = 3; + export interface DateRange { from: string; to: string; @@ -157,17 +168,26 @@ function recommendRating( largestAmount: number, totalAmount: number, escalated: boolean, - ruleCodes: string[] + ruleCodes: string[], + alertCount: number, + distinctMonths: number ): { risk: RiskLevel; reason: string } { const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW'; const grave = ruleCodes.find((c) => isNonMitigableRule(c)); const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal; - const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; + + // Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so + // bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over + // enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single + // month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only + // three times over. + const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER; + const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; // What the rating should be. Amounts only ever pull the rules' bound down, and // only where nothing weightier holds it up. let risk: RiskLevel; - let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate'; + let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin'; if (escalated) { risk = 'HIGH'; basis = 'escalated'; @@ -177,6 +197,12 @@ function recommendRating( } else if (material) { risk = implied; basis = 'material'; + } else if (!enoughActivity) { + // Too little observed to say anything about the rating either way, so it stands. Stating this rather than + // quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin, + // not that the customer was examined and found unremarkable. + risk = currentRisk ?? implied; + basis = 'thin'; } else { risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW'; basis = small ? 'small' : 'moderate'; @@ -220,6 +246,27 @@ function recommendRating( }; } + if (basis === 'thin') { + // A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling + // them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues + // against it in the same row. The thin-period test is about lowering a rating that exists. + if (!currentRisk) { + return { + risk, + reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${ + alertCount === 1 ? '' : 's' + }. Rate the customer; behaviour can only be judged against a rating.` + }; + } + // Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether + // the customer needs watching for another month or another five. + const shortOn = + alertCount < MIN_ALERTS_TO_LOWER + ? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed` + : `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`; + return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` }; + } + // The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank // read as "not assessed yet" on screen, which is the opposite of what the review concluded. return { @@ -345,7 +392,9 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo // and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up. const raw = windows[clientId]; const staleWindow = - raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; + raw?.riskCategoryAtReview != null && + currentRisk != null && + RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; const window = staleWindow ? undefined : raw; const recommendation = window?.suggestedDirection ? { @@ -358,7 +407,11 @@ export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindo largestAmount, totalAmount, escalated, - alerts.map((a) => a.ruleCode) + alerts.map((a) => a.ruleCode), + alerts.length, + // Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one + // week is a busy fortnight, not five months of steady conduct. + new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size ); // Where the customer carries no rating there is nothing to move, so an diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 4509e26ef..6f0ee1ce1 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260916', - 'hash': 'c01615ee' + 'version': '260918', + 'hash': '0cef1b5a' }, 'allow_switching_backend_instance': true };