Add CC accounts tracking, AML rule trial and branch approval fixes

This commit is contained in:
2026-10-19 18:16:22 +05:30
parent 885b5897af
commit 8bf74afe24
100 changed files with 14954 additions and 2692 deletions
@@ -79,12 +79,25 @@
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Applies To</mat-label>
<mat-label>Account Type</mat-label>
<mat-select formControlName="txnAppliesTo">
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
<mat-option *ngFor="let a of accountTypes" [value]="a.value">{{ a.label }}</mat-option>
</mat-select>
<mat-hint>Which accounts the rule watches</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Direction</mat-label>
<mat-select formControlName="txnDirection">
<mat-option *ngFor="let d of directionOptions" [value]="d.value">{{ d.label }}</mat-option>
</mat-select>
<mat-hint>Credit = paisa aaya, Debit = paisa gaya</mat-hint>
</mat-form-field>
<div class="rule-cash-only">
<mat-checkbox formControlName="txnCashOnly">Cash transactions only</mat-checkbox>
</div>
<mat-form-field appearance="outline">
<mat-label>AML Check Status</mat-label>
<mat-select formControlName="amlCheckStatus">
@@ -105,6 +118,14 @@
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
</div>
<div *ngIf="isCcRule" class="rule-form-info">
<mat-icon>info_outline</mat-icon>
<span>
This rule will be listed under the CC chip. It does not fire on live transactions yet: the engine is not yet fed
current-account transactions.
</span>
</div>
<p class="rule-form-note">
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
</p>
@@ -86,3 +86,32 @@
grid-template-columns: minmax(0, 1fr);
}
}
.rule-cash-only {
display: flex;
align-items: center;
min-height: 56px;
}
.rule-form-info {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0 0;
padding: 10px 14px;
border: 1px solid #bfdbfe;
border-left: 4px solid #1d4ed8;
border-radius: 8px;
background: #eff6ff;
color: #1e3a8a;
font-size: 12.5px;
line-height: 1.45;
mat-icon {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: #1d4ed8;
}
}
@@ -13,6 +13,7 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@@ -41,6 +42,7 @@ import { ClientsService } from '../../../clients/clients.service';
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatCheckboxModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
@@ -74,11 +76,23 @@ export class AddAmlRuleDialogComponent {
'STRUCTURING',
'VELOCITY'
];
readonly appliesToOptions = [
'ALL',
'SAVINGS',
'LOAN',
'TRANSFER'
/**
* Which kind of account the rule is for. CC is a Cash Credit account (a current account with an overdraft): the engine is
* not yet fed current-account transactions, so a CC rule is listed under the CC chip but does not fire until it is.
*/
readonly accountTypes = [
{ value: 'ALL', label: 'All accounts' },
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'LOAN', label: 'Loan' },
{ value: 'TRANSFER', label: 'Transfer' },
{ value: 'CURRENT', label: 'CC (Cash Credit)' }
];
/** Which way the money moves for the rule to apply. Left open, it applies to both. */
readonly directionOptions = [
{ value: '', label: 'Credit and debit (both)' },
{ value: 'CREDIT', label: 'Credit only (money comes in)' },
{ value: 'DEBIT', label: 'Debit only (money goes out)' }
];
readonly amlCheckStatuses = [
'CLEAR',
@@ -112,6 +126,8 @@ export class AddAmlRuleDialogComponent {
txnWindowDays: [0],
txnRuleType: ['THRESHOLD'],
txnAppliesTo: ['ALL'],
txnDirection: [''],
txnCashOnly: [false],
txnMinLimitAmount: [
null as
| number
@@ -131,6 +147,11 @@ export class AddAmlRuleDialogComponent {
dormantFlag: ['ACTIVE']
});
/** A CC rule is catalogued now and starts firing once current-account transactions reach the engine. */
get isCcRule(): boolean {
return this.ruleForm.value.txnAppliesTo === 'CURRENT';
}
/** Mirrors the backend band check so the officer is told before the round trip. */
get bandInverted(): boolean {
const min = this.ruleForm.value.txnMinLimitAmount;
@@ -179,6 +200,9 @@ export class AddAmlRuleDialogComponent {
txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null,
txnRuleType: v.txnRuleType,
txnAppliesTo: v.txnAppliesTo,
txnDirection: v.txnDirection ? v.txnDirection : null,
txnCashOnly: !!v.txnCashOnly,
ccRelevant: v.txnAppliesTo === 'CURRENT',
txnMinLimitAmount: amount(v.txnMinLimitAmount),
txnMaxLimitAmount: amount(v.txnMaxLimitAmount)
};
@@ -0,0 +1,595 @@
<!--
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="cc-intro">
<mat-icon>account_balance_wallet</mat-icon>
<div>
A Cash Credit (CC) account is a current account that may draw an overdraft against a sanctioned limit. For each one,
this shows how much came in and how much went out, against the turnover the customer declared and the limit the bank
sanctioned. It is read from the posted transactions, so it is live now, while the scenarios for CC are still being
added to the engine.
</div>
</div>
<div class="cc-controls">
<label class="cc-field">
<span>Window</span>
<select [(ngModel)]="days" (ngModelChange)="load()">
@for (w of windows; track w.days) {
<option [ngValue]="w.days">{{ w.label }}</option>
}
</select>
</label>
<span class="cc-asof">
@if (asOf) {
{{ from | date: 'dd MMM yyyy' }} to {{ asOf | date: 'dd MMM yyyy' }}
}
</span>
<button type="button" class="cc-refresh" (click)="load()" matTooltip="Reload">
<mat-icon>refresh</mat-icon>
</button>
</div>
<div class="cc-cards">
<div class="cc-card cc-card--in">
<div class="cc-card__n">₹{{ totalIn | number: '1.2-2' }}</div>
<div class="cc-card__l">Came in</div>
</div>
<div class="cc-card cc-card--out">
<div class="cc-card__n">₹{{ totalOut | number: '1.2-2' }}</div>
<div class="cc-card__l">Went out</div>
</div>
<div class="cc-card">
<div class="cc-card__n">{{ shown.length }}</div>
<div class="cc-card__l">CC accounts</div>
</div>
</div>
@if (loading) {
<div class="cc-note">Loading the CC account figures…</div>
} @else if (error) {
<div class="cc-note cc-note--warn">{{ error }}</div>
} @else if (!shown.length) {
<div class="cc-note">
No CC account in your branch yet. A CC account is a current account opened on a product that allows an overdraft
(Client page, Applications, New CC Account).
</div>
} @else {
<div class="cc-table-wrap">
<table class="cc-table">
<thead>
<tr>
<th>#</th>
<th>Customer / Account</th>
<th>Branch</th>
<th class="r">Limit</th>
<th class="r">Drawn</th>
<th class="r">Declared / month</th>
<th class="r">Came in</th>
<th class="r">Went out</th>
<th class="r">Net</th>
<th class="r">In ÷ declared</th>
<th class="r">Cash in</th>
<th>Last txn</th>
<th>CC rules</th>
<th class="r">Details</th>
</tr>
</thead>
<tbody>
@for (a of shown; track a.accountId; let i = $index) {
<tr class="cc-row" (click)="openTrail(a, 'analysis')">
<td>{{ i + 1 }}</td>
<td>
<div class="cc-name">
{{ a.clientName }}
<span class="cc-badge" [class.cc-badge--cc]="a.isCc">{{ a.isCc ? 'CC' : 'Current' }}</span>
</div>
<div class="cc-sub mono">{{ a.accountNo }}</div>
</td>
<td>{{ a.branchName || '—' }}</td>
<td class="r">{{ a.isCc ? '₹' + (a.sanctionedLimit | number: '1.0-0') : '—' }}</td>
<td class="r">{{ a.limitDrawnPercent != null ? a.limitDrawnPercent + '%' : '—' }}</td>
<td class="r">
{{
a.declaredMonthlyTurnover != null ? '₹' + (a.declaredMonthlyTurnover | number: '1.0-0') : 'not declared'
}}
</td>
<td class="r in">
₹{{ a.credits | number: '1.2-2' }}
<div class="cc-sub">{{ a.creditCount }} txn</div>
</td>
<td class="r out">
₹{{ a.debits | number: '1.2-2' }}
<div class="cc-sub">{{ a.debitCount }} txn</div>
</td>
<td class="r" [class.in]="a.net > 0" [class.out]="a.net < 0">{{ rs(a.net) }}</td>
<td class="r">
@if (a.creditsVsDeclared != null) {
<span
class="cc-ratio"
[class.cc-ratio--hi]="isSpike(a.creditsVsDeclared)"
[matTooltip]="isSpike(a.creditsVsDeclared) ? 'Well above the declared turnover for this window' : ''"
>
{{ a.creditsVsDeclared }}×
</span>
} @else {
—
}
</td>
<td class="r">{{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}</td>
<td>{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }}</td>
<td
class="cc-rules-cell"
(click)="openTrail(a, 'rules'); $event.stopPropagation()"
title="See which CC rules this account hits"
>
<span class="cc-hit" [class.cc-hit--on]="a.rulesHit > 0">{{ a.rulesHit }} hit</span>
<div class="cc-sub">{{ a.rulesNoHit }} no hit · {{ a.rulesNotChecked }} not checked</div>
</td>
<td class="r cc-actions">
<button
type="button"
class="cc-trail-btn cc-trail-btn--main"
(click)="openTrail(a, 'analysis'); $event.stopPropagation()"
>
Analysis
</button>
<button type="button" class="cc-trail-btn" (click)="openTrail(a, 'trail'); $event.stopPropagation()">
Trail
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="cc-foot">
Busiest first. “In ÷ declared” compares what came in with the turnover the customer declared, scaled to the window;
{{ spikeMultiple }}× or more is highlighted for a look. It is an indicative starting value, not a finding.
</p>
}
<!-- Where one account's money came from and went, and what it adds up to. Opened from a row above; declared here so it
keeps this component's state and styles. Credits are written Cr and debits Dr, as in the bank's books. -->
<ng-template #trailDialog>
<div class="cc-dialog">
@if (trailLoading) {
<div class="cc-note">Loading the account's money trail…</div>
} @else if (trailError) {
<div class="cc-note cc-note--warn">{{ trailError }}</div>
<div class="cc-dialog__bar"><button type="button" class="cc-btn" (click)="closeTrail()">Close</button></div>
} @else if (trail) {
<div class="cc-dialog__head">
<div>
<div class="cc-dialog__title">
{{ trail.clientName }}
<span class="cc-badge" [class.cc-badge--cc]="trail.isCc">{{ trail.isCc ? 'CC' : 'Current' }}</span>
</div>
<div class="cc-sub mono">{{ trail.accountNo }} · {{ trail.branchName }}</div>
<div class="cc-sub">
{{ trail.from | date: 'dd MMM yyyy' }} to {{ trail.asOf | date: 'dd MMM yyyy' }} · Sanctioned limit ₹{{
trail.sanctionedLimit | number: '1.0-0'
}}
· Closing balance <strong>{{ drcr(trail.balance) }}</strong>
</div>
</div>
<button type="button" class="cc-btn" (click)="closeTrail()">Close</button>
</div>
<div class="cc-legend">
<span><strong class="in">Cr (credit)</strong> = money received into the account.</span>
<span><strong class="out">Dr (debit)</strong> = money paid out of the account.</span>
<span
>On a CC account, a <strong class="out">Dr balance</strong> means the customer has drawn on the overdraft,
that is, owes the bank.</span
>
</div>
<div class="cc-tabs">
<button
type="button"
class="cc-tab"
[class.cc-tab--on]="trailView === 'analysis'"
(click)="trailView = 'analysis'"
>
Analysis
</button>
<button
type="button"
class="cc-tab"
[class.cc-tab--on]="trailView === 'rules'"
(click)="trailView = 'rules'; ruleFilter = 'HIT'"
>
CC rules ({{ ruleCount('HIT') }} hit)
</button>
<button type="button" class="cc-tab" [class.cc-tab--on]="trailView === 'trail'" (click)="trailView = 'trail'">
Every transaction ({{ trail.transactions.length }})
</button>
</div>
@if (trailView === 'rules') {
<!-- Which CC rules this account hits, and which it does not -->
<div class="cc-rulesbar">
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'HIT'" (click)="ruleFilter = 'HIT'">
Hit <span class="cc-chip__n">{{ ruleCount('HIT') }}</span>
</button>
<button
type="button"
class="cc-chip"
[class.cc-chip--on]="ruleFilter === 'NO_HIT'"
(click)="ruleFilter = 'NO_HIT'"
>
No hit <span class="cc-chip__n">{{ ruleCount('NO_HIT') }}</span>
</button>
<button
type="button"
class="cc-chip"
[class.cc-chip--on]="ruleFilter === 'NOT_CHECKED'"
(click)="ruleFilter = 'NOT_CHECKED'"
>
Not checked <span class="cc-chip__n">{{ ruleCount('NOT_CHECKED') }}</span>
</button>
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'ALL'" (click)="ruleFilter = 'ALL'">
All <span class="cc-chip__n">{{ ruleCount('') }}</span>
</button>
</div>
<p class="cc-hint cc-hint--top">
<strong>Hit</strong> = this account's transactions trigger the rule. <strong>No hit</strong> = the rule was
checked and not triggered. <strong>Not checked</strong> = the rule needs an officer's judgement or data the
system does not hold yet, so it is not shown as passed.
</p>
<div class="cc-table-wrap">
<table class="cc-table">
<thead>
<tr>
<th>Rule</th>
<th>Kind</th>
<th>Risk</th>
<th>Result</th>
<th>Why</th>
</tr>
</thead>
<tbody>
@for (r of filteredRuleChecks; track r.ruleId) {
<tr>
<td>
{{ r.name }}
@if (r.onTrial) {
<span class="cc-trial">TRIAL</span>
}
</td>
<td>{{ r.kind }}</td>
<td>{{ riskLabel(r.riskCategory) }}</td>
<td>
<span
class="cc-res"
[class.cc-res--hit]="r.status === 'HIT'"
[class.cc-res--ok]="r.status === 'NO_HIT'"
[class.cc-res--na]="r.status === 'NOT_CHECKED'"
>
{{ r.status === 'HIT' ? 'HIT' : r.status === 'NO_HIT' ? 'No hit' : 'Not checked' }}
</span>
</td>
<td class="cc-sub-cell">{{ r.detail }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="cc-empty">No rule in this group.</td>
</tr>
}
</tbody>
</table>
</div>
} @else if (trailView === 'analysis') {
<!-- In words -->
<div class="cc-story">
<div class="cc-story__title">In short</div>
@for (line of story; track $index) {
<p>{{ line }}</p>
}
</div>
<!-- The figures -->
<div class="cc-kpis">
<div class="cc-kpi cc-kpi--in">
<div class="cc-kpi__t">Total credits (Cr)</div>
<div class="cc-kpi__n">₹{{ trail.summary.in | number: '1.2-2' }}</div>
<div class="cc-kpi__l">
{{ trail.summary.inCount }} credit{{ trail.summary.inCount === 1 ? '' : 's' }} · money received
</div>
</div>
<div class="cc-kpi cc-kpi--out">
<div class="cc-kpi__t">Total debits (Dr)</div>
<div class="cc-kpi__n">₹{{ trail.summary.out | number: '1.2-2' }}</div>
<div class="cc-kpi__l">
{{ trail.summary.outCount }} debit{{ trail.summary.outCount === 1 ? '' : 's' }} · money paid out
</div>
</div>
<div class="cc-kpi">
<div class="cc-kpi__t">Net (Cr − Dr)</div>
<div class="cc-kpi__n" [class.in]="trail.summary.net > 0" [class.out]="trail.summary.net < 0">
{{ drcr(trail.summary.net) }}
</div>
<div class="cc-kpi__l">
{{
trail.summary.net < 0
? 'Debits were more than credits'
: trail.summary.net > 0
? 'Credits were more than debits'
: 'Credits equal debits'
}}
</div>
</div>
<div class="cc-kpi">
<div class="cc-kpi__t">Closing balance</div>
<div class="cc-kpi__n" [class.in]="trail.balance > 0" [class.out]="trail.balance < 0">
{{ drcr(trail.balance) }}
</div>
<div class="cc-kpi__l">
{{
trail.balance < 0
? 'Overdrawn: the customer owes the bank'
: trail.balance > 0
? "The customer's own money"
: 'Nil'
}}
</div>
</div>
<div class="cc-kpi">
<div class="cc-kpi__t">Lowest balance</div>
<div class="cc-kpi__n">{{ drcr(trail.summary.lowestBalance, '1.0-0') }}</div>
<div class="cc-kpi__l">
{{
trail.summary.peakLimitUsedPercent != null
? trail.summary.peakLimitUsedPercent + '% of the limit used at the worst point'
: 'Never overdrawn'
}}
</div>
</div>
</div>
<div class="cc-facts">
<span
>Active days: <strong>{{ trail.summary.activeDays }}</strong></span
>
<span
>Average credit:
<strong>{{
trail.summary.averageIn != null ? '₹' + (trail.summary.averageIn | number: '1.0-0') : '—'
}}</strong></span
>
<span
>Average debit:
<strong>{{
trail.summary.averageOut != null ? '₹' + (trail.summary.averageOut | number: '1.0-0') : '—'
}}</strong></span
>
<span>
Declared turnover for the window:
<strong>{{
trail.summary.declaredForWindow != null
? '₹' + (trail.summary.declaredForWindow | number: '1.0-0')
: 'not declared'
}}</strong>
</span>
<span>
Credits ÷ declared:
<strong>{{ trail.summary.creditsVsDeclared != null ? trail.summary.creditsVsDeclared + '×' : '—' }}</strong>
</span>
</div>
<!-- What stands out -->
<div class="cc-group__title cc-group__title--pad">What stands out</div>
<div class="cc-obs">
@for (o of trail.observations; track $index) {
<div class="cc-ob" [ngClass]="'cc-ob--' + o.level.toLowerCase()">
<span class="cc-ob__lvl">{{ o.level }}</span>
<div>
<div class="cc-ob__t">{{ o.title }}</div>
<div class="cc-ob__d">{{ o.detail }}</div>
@if (o.why) {
<div class="cc-ob__w"><strong>Why it matters:</strong> {{ o.why }}</div>
}
</div>
</div>
}
</div>
<div class="cc-groups">
<!-- By mode -->
<div class="cc-group">
<div class="cc-group__title">By mode of transaction</div>
<table class="cc-mini">
<thead>
<tr>
<th>Mode</th>
<th class="r">Credit (Cr)</th>
<th class="r">Debit (Dr)</th>
<th class="r">Net</th>
<th class="r">Txns</th>
<th class="r">Share</th>
</tr>
</thead>
<tbody>
@for (m of trail.byMode; track m.label) {
<tr>
<td>{{ m.label }}</td>
<td class="r in">₹{{ m.in | number: '1.0-0' }}</td>
<td class="r out">₹{{ m.out | number: '1.0-0' }}</td>
<td class="r">{{ drcr(m.in - m.out, '1.0-0') }}</td>
<td class="r">{{ m.count }}</td>
<td class="r">
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="m.sharePercent"></span></span>
{{ m.sharePercent }}%
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="cc-empty">No transactions in this window.</td>
</tr>
}
</tbody>
</table>
<p class="cc-hint">Share = this mode's part of all the money that moved (credits + debits).</p>
</div>
<!-- By the party on the other side -->
<div class="cc-group">
<div class="cc-group__title">Who the money came from (Cr) and went to (Dr)</div>
<table class="cc-mini">
<thead>
<tr>
<th>Account / Bank</th>
<th class="r">Credit (Cr)</th>
<th class="r">Debit (Dr)</th>
<th class="r">Net</th>
<th class="r">Txns</th>
<th class="r">Share</th>
</tr>
</thead>
<tbody>
@for (p of trail.byCounterparty; track $index) {
<tr>
<td>
<span class="mono">{{ p.label }}</span>
<div class="cc-sub">{{ p.sub || '' }}</div>
</td>
<td class="r in">₹{{ p.in | number: '1.0-0' }}</td>
<td class="r out">₹{{ p.out | number: '1.0-0' }}</td>
<td class="r">{{ drcr(p.in - p.out, '1.0-0') }}</td>
<td class="r">{{ p.count }}</td>
<td class="r">
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="p.sharePercent"></span></span>
{{ p.sharePercent }}%
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="cc-empty">—</td>
</tr>
}
</tbody>
</table>
<p class="cc-hint">Cr from a party = it paid this account. Dr to a party = this account paid it.</p>
</div>
</div>
<!-- Week by week -->
<div class="cc-group__title cc-group__title--pad">Week by week</div>
<table class="cc-mini cc-weeks">
<thead>
<tr>
<th>Week starting</th>
<th class="r">Credit (Cr)</th>
<th class="r">Debit (Dr)</th>
<th class="r">Net for the week</th>
<th class="r">Txns</th>
</tr>
</thead>
<tbody>
@for (w of trail.timeline; track w.label) {
<tr>
<td>{{ w.label | date: 'dd MMM yyyy' }}</td>
<td class="r in">₹{{ w.in | number: '1.0-0' }}</td>
<td class="r out">₹{{ w.out | number: '1.0-0' }}</td>
<td class="r">{{ drcr(w.in - w.out, '1.0-0') }}</td>
<td class="r">{{ w.count }}</td>
</tr>
}
</tbody>
</table>
<!-- The biggest two -->
<div class="cc-big">
@if (trail.summary.largestIn) {
<div class="cc-big__item">
Largest credit (Cr): <strong class="in">₹{{ trail.summary.largestIn.amount | number: '1.2-2' }}</strong
>, received from {{ trail.summary.largestIn.party }} by {{ trail.summary.largestIn.mode }} on
{{ trail.summary.largestIn.date | date: 'dd MMM yyyy' }}.
</div>
}
@if (trail.summary.largestOut) {
<div class="cc-big__item">
Largest debit (Dr): <strong class="out">₹{{ trail.summary.largestOut.amount | number: '1.2-2' }}</strong
>, paid to {{ trail.summary.largestOut.party }} by {{ trail.summary.largestOut.mode }} on
{{ trail.summary.largestOut.date | date: 'dd MMM yyyy' }}.
</div>
}
</div>
<p class="cc-foot">
Observations use fixed starting values (for example 3× the declared turnover, a credit mostly paid out again
within 3 days, one party above half of all the money moved). They point at something worth a look; they are
not findings, and the bank's compliance team sets the real thresholds.
</p>
} @else {
<div class="cc-table-wrap">
<table class="cc-table">
<thead>
<tr>
<th>Date</th>
<th>Cr / Dr</th>
<th>Mode</th>
<th>Other side</th>
<th>Reference</th>
<th class="r">Credit (Cr)</th>
<th class="r">Debit (Dr)</th>
<th class="r">Balance after</th>
</tr>
</thead>
<tbody>
@for (t of trail.transactions; track t.id) {
<tr>
<td>{{ t.date | date: 'dd MMM yyyy' }}</td>
<td>
<span
class="cc-dir"
[class.cc-dir--in]="t.direction === 'IN'"
[class.cc-dir--out]="t.direction === 'OUT'"
>
{{ t.direction === 'IN' ? 'Cr · received' : 'Dr · paid out' }}
</span>
</td>
<td>
{{ t.mode }}
@if (t.chequeNo) {
<div class="cc-sub">No. {{ t.chequeNo }}</div>
}
</td>
<td>
<span class="mono">{{ t.counterpartyAccount || '—' }}</span>
<div class="cc-sub">
{{ t.counterpartyBank || '' }}{{ t.counterpartyIfsc ? ' · ' + t.counterpartyIfsc : '' }}
</div>
</td>
<td>
<span class="mono">{{ t.reference || '—' }}</span>
<div class="cc-sub">{{ t.remarks }}</div>
</td>
<td class="r in">{{ t.direction === 'IN' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
<td class="r out">{{ t.direction === 'OUT' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
<td class="r">{{ drcr(t.balanceAfter) }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="cc-empty">No transactions in this window.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="cc-foot">
“Mode” and the other side are what the payment recorded: cash, cheque (by its number), and NEFT, RTGS, IMPS,
UPI, demand draft or internal transfer where the payment says so. Where it recorded nothing, it shows as “Not
recorded”. “Balance after” is the account's balance once that transaction was posted: Dr when overdrawn, Cr
when in credit.
</p>
}
}
</div>
</ng-template>
@@ -0,0 +1,770 @@
/**
* 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/.
*/
:host {
display: block;
max-width: 100%;
overflow-x: hidden;
}
.cc-intro {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 16px;
margin-bottom: 14px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
color: #1e3a8a;
font-size: 13px;
line-height: 1.5;
mat-icon {
flex-shrink: 0;
color: #1d4ed8;
}
}
.cc-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 14px;
}
.cc-field {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
select {
padding: 6px 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 13px;
text-transform: none;
letter-spacing: 0;
color: #0f172a;
}
}
.cc-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #334155;
}
.cc-asof {
font-size: 12px;
color: #64748b;
}
.cc-refresh {
margin-left: auto;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
cursor: pointer;
padding: 2px 6px;
color: #334155;
}
.cc-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.cc-card {
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-left: 4px solid #1a237e;
border-radius: 8px;
background: #fff;
&--in {
border-left-color: #15803d;
}
&--out {
border-left-color: #b91c1c;
}
&__n {
font-size: 20px;
font-weight: 800;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
&__l {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
}
.cc-note {
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-size: 13px;
&--warn {
border-color: #fcd34d;
background: #fffbeb;
color: #78350f;
}
}
.cc-table-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.cc-table {
width: 100%;
border-collapse: collapse;
th {
padding: 12px 6px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
text-align: left;
}
td {
padding: 10px 6px;
border-top: 1px solid #f1f5f9;
font-size: 13px;
color: #1e293b;
vertical-align: top;
}
.r {
text-align: right;
font-variant-numeric: tabular-nums;
}
.in {
color: #15803d;
font-weight: 600;
}
.out {
color: #b91c1c;
font-weight: 600;
}
}
.cc-name {
font-weight: 600;
}
.cc-sub {
margin-top: 1px;
font-size: 11px;
font-weight: 400;
color: #64748b;
}
.mono {
font-family: ui-monospace, monospace;
}
.cc-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 10px;
font-weight: 700;
&--cc {
background: #dbeafe;
color: #1d4ed8;
}
}
.cc-ratio {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
background: #f1f5f9;
font-weight: 700;
&--hi {
background: #fee2e2;
color: #b91c1c;
}
}
.cc-foot {
margin: 10px 2px 0;
font-size: 12px;
color: #64748b;
}
.cc-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
.cc-trail-btn,
.cc-btn {
padding: 5px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #1e3a8a;
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
&:hover {
background: #eff6ff;
}
}
/* The money-trail popup. */
.cc-dialog {
padding: 20px 28px 24px;
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
&__title {
font-size: 18px;
font-weight: 800;
color: #0f172a;
}
&__bar {
margin-top: 12px;
text-align: right;
}
}
.cc-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.cc-group {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
&__title {
padding: 8px 10px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
&--pad {
margin-bottom: 8px;
border-radius: 6px;
}
}
}
.cc-mini {
width: 100%;
border-collapse: collapse;
th {
padding: 6px 10px;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-align: left;
}
td {
padding: 7px 10px;
border-top: 1px solid #f1f5f9;
font-size: 13px;
color: #1e293b;
}
.r {
text-align: right;
font-variant-numeric: tabular-nums;
}
.in {
color: #15803d;
font-weight: 600;
}
.out {
color: #b91c1c;
font-weight: 600;
}
}
.cc-empty {
padding: 12px;
color: #64748b;
text-align: center;
}
.cc-dir {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
font-weight: 700;
&--in {
background: #dcfce7;
color: #15803d;
}
&--out {
background: #fee2e2;
color: #b91c1c;
}
}
/* The two buttons are stacked so the Details column stays narrow and the table fits the screen. */
.cc-actions {
width: 84px;
.cc-trail-btn {
display: block;
width: 100%;
}
.cc-trail-btn + .cc-trail-btn {
margin-top: 6px;
}
}
.cc-trail-btn--main {
background: #1d4ed8;
border-color: #1d4ed8;
color: #fff;
&:hover {
background: #1e40af;
}
}
.cc-tabs {
display: flex;
gap: 8px;
margin-bottom: 14px;
border-bottom: 1px solid #e2e8f0;
}
.cc-tab {
padding: 8px 14px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: #64748b;
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
&--on {
border-bottom-color: #1d4ed8;
color: #1d4ed8;
}
}
.cc-kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.cc-kpi {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-left: 4px solid #1a237e;
border-radius: 8px;
background: #fff;
&--in {
border-left-color: #15803d;
}
&--out {
border-left-color: #b91c1c;
}
&__n {
font-size: 16px;
font-weight: 800;
color: #0f172a;
font-variant-numeric: tabular-nums;
&.in {
color: #15803d;
}
&.out {
color: #b91c1c;
}
}
&__l {
margin-top: 2px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #64748b;
}
}
.cc-obs {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.cc-ob {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-left: 4px solid #94a3b8;
border-radius: 8px;
background: #f8fafc;
&__lvl {
flex-shrink: 0;
min-width: 58px;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 10px;
font-weight: 800;
text-align: center;
}
&__t {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
&__d {
margin-top: 1px;
font-size: 12.5px;
line-height: 1.45;
color: #475569;
}
&--high {
border-left-color: #dc2626;
background: #fef2f2;
.cc-ob__lvl {
background: #fee2e2;
color: #b91c1c;
}
}
&--medium {
border-left-color: #f59e0b;
background: #fffbeb;
.cc-ob__lvl {
background: #fef3c7;
color: #92400e;
}
}
&--ok {
border-left-color: #16a34a;
background: #f0fdf4;
.cc-ob__lvl {
background: #dcfce7;
color: #15803d;
}
}
}
.cc-bar {
display: inline-block;
width: 54px;
height: 6px;
margin-right: 6px;
border-radius: 3px;
background: #e2e8f0;
vertical-align: middle;
overflow: hidden;
&__fill {
display: block;
height: 100%;
background: #1d4ed8;
}
}
.cc-weeks {
margin-bottom: 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.cc-big {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: #334155;
.in {
color: #15803d;
}
.out {
color: #b91c1c;
}
}
.cc-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 22px;
margin: 0 0 14px;
padding: 9px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
font-size: 12.5px;
color: #475569;
.in {
color: #15803d;
}
.out {
color: #b91c1c;
}
}
.cc-story {
margin-bottom: 16px;
padding: 14px 18px;
border: 1px solid #bfdbfe;
border-left: 4px solid #1d4ed8;
border-radius: 8px;
background: #eff6ff;
color: #1e3a8a;
&__title {
margin-bottom: 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
p {
margin: 0 0 6px;
font-size: 13.5px;
line-height: 1.55;
}
p:last-child {
margin-bottom: 0;
}
}
.cc-kpi__t {
margin-bottom: 3px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #334155;
}
.cc-facts {
display: flex;
flex-wrap: wrap;
gap: 6px 26px;
margin: -4px 0 16px;
font-size: 12.5px;
color: #475569;
strong {
color: #0f172a;
}
}
.cc-ob__w {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
color: #64748b;
font-style: italic;
}
.cc-hint {
margin: 0;
padding: 6px 10px 8px;
font-size: 11.5px;
color: #64748b;
}
.cc-rules-cell {
cursor: pointer;
white-space: nowrap;
}
.cc-hit {
display: inline-block;
padding: 1px 9px;
border-radius: 999px;
background: #dcfce7;
color: #15803d;
font-size: 12px;
font-weight: 800;
&--on {
background: #fee2e2;
color: #b91c1c;
}
}
.cc-rulesbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.cc-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
color: #334155;
font: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
&--on {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&__n {
padding: 0 7px;
border-radius: 999px;
background: rgb(0 0 0 / 10%);
font-size: 11px;
}
}
.cc-hint--top {
padding: 2px 2px 10px;
}
.cc-res {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
&--hit {
background: #fee2e2;
color: #b91c1c;
}
&--ok {
background: #dcfce7;
color: #15803d;
}
&--na {
background: #e2e8f0;
color: #64748b;
}
}
.cc-trial {
display: inline-block;
margin-left: 6px;
padding: 0 7px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 10px;
font-weight: 800;
}
.cc-sub-cell {
font-size: 12.5px;
color: #475569;
}
/* A negative amount stays on one line: -₹1,00,000.00, not a minus on one line and the amount on the next. */
.cc-table td.r {
white-space: nowrap;
}
@@ -0,0 +1,223 @@
/**
* 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, OnInit, inject, ViewChild, TemplateRef } from '@angular/core';
import { CommonModule, formatNumber, formatDate } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { AmlService } from '../../../aml/aml.service';
/**
* How much came into and went out of each Cash Credit account, against what the customer declared and what the bank
* sanctioned. Read from the posted current-account transactions, so it works without the AML engine being fed them.
*/
@Component({
selector: 'mifosx-aml-cc-tracking',
templateUrl: './aml-cc-tracking.component.html',
styleUrls: ['./aml-cc-tracking.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatTooltipModule
]
})
export class AmlCcTrackingComponent implements OnInit {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
/** The money trail of one account, in a popup. */
@ViewChild('trailDialog') trailDialogTpl!: TemplateRef<unknown>;
private trailRef: MatDialogRef<unknown> | null = null;
trail: any = null;
trailLoading = false;
/** What the popup is showing: the analysis, or every transaction. */
trailView: 'analysis' | 'rules' | 'trail' = 'analysis';
/** Which of the account's CC rule checks to list: all of them, or only those that hit, did not hit, or were not checked. */
ruleFilter: 'ALL' | 'HIT' | 'NO_HIT' | 'NOT_CHECKED' = 'ALL';
trailError = '';
readonly windows = [
{ days: 7, label: 'Last 7 days' },
{ days: 30, label: 'Last 30 days' },
{ days: 90, label: 'Last 90 days' }
];
days = 30;
loading = false;
error = '';
asOf = '';
from = '';
accounts: any[] = [];
/** A credit or debit this many times the declared turnover for the window is flagged for a look. Indicative only. */
readonly spikeMultiple = 3;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.amlService.getCcTracking(this.days).subscribe({
next: (res) => {
this.loading = false;
this.accounts = res?.accounts || [];
this.asOf = res?.asOf || '';
this.from = res?.from || '';
},
error: (err: any) => {
this.loading = false;
this.accounts = [];
this.error = err?.error?.defaultUserMessage || 'Could not load the CC account figures.';
}
});
}
/** The server returns only Cash Credit accounts. */
get shown(): any[] {
return this.accounts;
}
get totalIn(): number {
return this.shown.reduce((sum, a) => sum + (Number(a.credits) || 0), 0);
}
get totalOut(): number {
return this.shown.reduce((sum, a) => sum + (Number(a.debits) || 0), 0);
}
/** Opens where one account's money came from and went: each transaction, its mode, and the party on the other side. */
openTrail(account: any, view: 'analysis' | 'rules' | 'trail' = 'analysis'): void {
this.trailView = view;
this.ruleFilter = view === 'rules' ? 'HIT' : 'ALL';
this.trail = null;
this.trailError = '';
this.trailLoading = true;
this.amlService.getCcAccountTrail(account.accountId, this.days).subscribe({
next: (res) => {
this.trail = res;
this.trailLoading = false;
},
error: (err: any) => {
this.trailLoading = false;
this.trailError = err?.error?.defaultUserMessage || 'Could not load the money trail for this account.';
}
});
this.trailRef?.close();
this.trailRef = this.dialog.open(this.trailDialogTpl, {
width: '1500px',
maxWidth: '98vw',
height: '94vh',
maxHeight: '96vh',
autoFocus: false
});
this.trailRef.afterClosed().subscribe(() => (this.trailRef = null));
}
/** How many of the open account's CC rule checks have the given result. */
ruleCount(status: string): number {
return (this.trail?.ruleChecks || []).filter((r: any) => !status || r.status === status).length;
}
/** The open account's CC rule checks, narrowed to the chosen result. */
get filteredRuleChecks(): any[] {
const all: any[] = this.trail?.ruleChecks || [];
return this.ruleFilter === 'ALL' ? all : all.filter((r) => r.status === this.ruleFilter);
}
riskLabel(category: number): string {
return category === 3 ? 'High' : category === 2 ? 'Medium' : category === 1 ? 'Low' : '—';
}
closeTrail(): void {
this.trailRef?.close();
}
/**
* A balance the way a bank writes it: Dr for a debit balance (on a CC account, the overdraft drawn - the customer owes
* the bank), Cr for a credit balance (the customer's own money), and a bare amount for nil.
*/
drcr(value: number | string | null | undefined, digits = '1.2-2'): string {
if (value === null || value === undefined || value === '') return '—';
const n = Number(value);
if (n === 0) return `₹${formatNumber(0, 'en-US', digits)}`;
return `₹${formatNumber(Math.abs(n), 'en-US', digits)} ${n < 0 ? 'Dr' : 'Cr'}`;
}
/** The account's activity told in plain words, from the same figures the cards and tables show. */
get story(): string[] {
const t = this.trail;
if (!t?.summary) return [];
const s = t.summary;
const day = (iso: string) => formatDate(iso, 'dd MMM yyyy', 'en-US');
const money = (n: number) => `₹${formatNumber(Math.abs(Number(n)), 'en-US', '1.2-2')}`;
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
const lines: string[] = [];
lines.push(
`From ${day(t.from)} to ${day(t.asOf)}, this ${t.isCc ? 'Cash Credit' : 'current'} account received ${money(s.in)} (Cr) in ` +
`${plural(s.inCount, 'credit')} and paid out ${money(s.out)} (Dr) in ${plural(s.outCount, 'debit')}.`
);
const net = Number(s.net);
lines.push(
net < 0
? `Debits were ${money(net)} more than credits, so more money went out than came in.`
: net > 0
? `Credits were ${money(net)} more than debits, so more money came in than went out.`
: 'Credits and debits were equal.'
);
const bal = Number(t.balance);
const limit = Number(t.sanctionedLimit) || 0;
if (bal < 0) {
lines.push(
`The account now stands at ${money(bal)} Dr: it is overdrawn, which means the customer has used ${money(bal)} of the ` +
`bank's money${limit > 0 ? `, ${((-bal * 100) / limit).toFixed(1)}% of the ${money(limit)} sanctioned limit` : ''}.`
);
} else if (bal > 0) {
lines.push(
`The account now stands at ${money(bal)} Cr: the customer's own money is in it and none of the limit is in use.`
);
} else {
lines.push('The account now stands at nil.');
}
if (s.lowestBalance != null && Number(s.lowestBalance) < 0) {
lines.push(
`At its lowest it was ${money(s.lowestBalance)} Dr${s.peakLimitUsedPercent != null ? `, which is ${s.peakLimitUsedPercent}% of the limit` : ''}.`
);
}
const mode = t.byMode?.[0];
if (mode) {
lines.push(`Most of the money moved by ${mode.label} (${mode.sharePercent}% of everything).`);
}
const party = (t.byCounterparty || []).find((p: any) => p.label !== 'Not recorded');
if (party) {
lines.push(
`The party handling the most money was ${party.label}${party.sub ? ' (' + party.sub + ')' : ''}: ` +
`${money(party.in)} Cr from it and ${money(party.out)} Dr to it.`
);
}
return lines;
}
/** An amount with its sign in front of the rupee sign: -₹1,00,000.00, not ₹-1,00,000.00. */
rs(value: number | string | null | undefined, digits = '1.2-2'): string {
if (value === null || value === undefined || value === '') return '—';
const n = Number(value);
return `${n < 0 ? '-' : ''}₹${formatNumber(Math.abs(n), 'en-US', digits)}`;
}
/** True when a side of the account's activity is well beyond what the customer declared for the window. */
isSpike(ratio: number | null): boolean {
return ratio != null && ratio >= this.spikeMultiple;
}
}
@@ -303,153 +303,6 @@
</td>
</ng-container>
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
opened, so the detail sits directly under the row it belongs to. -->
<ng-container matColumnDef="expandedDetail">
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
clicks from reaching it — otherwise ticking a rule closes the pane. -->
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
@if (isExpanded(alert)) {
<div class="detail-inner">
<div class="detail-head">
<span
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
{{ amount(alert) }}</span
>
@if (selectableRulesOf(alert).length > 1) {
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
</button>
}
</div>
<!-- Governing rule first: it is the one the alert was raised under, and the one the
disposition is recorded against. -->
@for (rule of matchedRules(alert); track rule.ruleCode) {
<div
class="detail-rule"
[class.detail-rule-governing]="rule.governing"
[class.detail-rule-decided]="rule.disposition"
>
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
customer's business and the sixth is not, and those are different decisions. A rule already
decided shows its verdict instead. -->
@if (isRuleSelectable(rule)) {
<mat-checkbox
[checked]="isRuleSelected(rule)"
(change)="toggleRuleSelected(rule)"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
></mat-checkbox>
} @else if (!isGradedRule(rule)) {
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
about the transaction, so there is nothing here to tick. -->
<mat-icon
class="rule-decided-icon decided-statutory"
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
matTooltipClass="evidence-tooltip"
>
gavel
</mat-icon>
} @else {
<mat-icon
class="rule-decided-icon"
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
[class.decided-carried]="!rule.directlyDecided"
[matTooltip]="
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
"
matTooltipClass="evidence-tooltip"
>
{{
!rule.directlyDecided
? 'remove_circle_outline'
: rule.disposition === 'ESCALATE'
? 'report'
: 'verified'
}}
</mat-icon>
}
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
severityLabel(rule.riskCategory)
}}</span>
<div class="detail-rule-text">
<span class="detail-rule-code">
{{ rule.ruleCode }}
@if (rule.governing) {
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
>governing</span
>
}
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
transaction being unreported. -->
@if (!isGradedRule(rule)) {
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
→ CTR filing
</span>
}
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
because an audit of the finding turns on which one they put their name to. -->
@if (rule.disposition) {
<span
class="rule-verdict"
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
[class.verdict-carried]="!rule.directlyDecided"
[matTooltip]="
rule.directlyDecided
? ruleDispositionLabel(rule) +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
"
>
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
</span>
}
</span>
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
</div>
</div>
}
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
clearing five of six rules leaves the case open on the sixth. -->
@if (selectedRuleCount > 0) {
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<span class="rule-action-count"
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
<button
mat-stroked-button
color="primary"
type="button"
(click)="reviewSelectedRules(alert, 'WHITELIST')"
>
Whitelist
</button>
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
Suspicious
</button>
</div>
}
<div class="detail-foot">
@if (isHighSeverity(alert)) {
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
} @else if (!alert.highestSeverity) {
A statutory return rather than a graded suspicion — decided on its own.
} @else {
Every rule here is low or medium, so this transaction can be decided together with others.
}
</div>
</div>
}
</div>
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="col-right">Amount</th>
<td mat-cell *matCellDef="let alert" class="col-right amount-cell">{{ amount(alert) }}</td>
@@ -541,9 +394,147 @@
[class.row-expanded]="isExpanded(row)"
(click)="toggleExpanded(row)"
></tr>
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
</table>
<!-- Every rule a transaction tripped, in a popup rather than a pane pushed in under the row. Declared here so it keeps
this component's state and styles; opened by toggleExpanded(). -->
<ng-template #rulesDialog>
<div class="rules-dialog">
<button type="button" class="rules-dialog-close" mat-icon-button mat-dialog-close aria-label="Close">
<mat-icon>close</mat-icon>
</button>
@if (expandedAlert; as alert) {
<div class="detail-inner">
<div class="detail-head">
<span
>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} —
{{ amount(alert) }}</span
>
@if (selectableRulesOf(alert).length > 1) {
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
</button>
}
</div>
<!-- Governing rule first: it is the one the alert was raised under, and the one the
disposition is recorded against. -->
@for (rule of matchedRules(alert); track rule.ruleCode) {
<div
class="detail-rule"
[class.detail-rule-governing]="rule.governing"
[class.detail-rule-decided]="rule.disposition"
>
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
customer's business and the sixth is not, and those are different decisions. A rule already
decided shows its verdict instead. -->
@if (isRuleSelectable(rule)) {
<mat-checkbox
[checked]="isRuleSelected(rule)"
(change)="toggleRuleSelected(rule)"
*mifosxHasPermission="'REVIEW_AML_ALERT'"
></mat-checkbox>
} @else if (!isGradedRule(rule)) {
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
about the transaction, so there is nothing here to tick. -->
<mat-icon
class="rule-decided-icon decided-statutory"
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
matTooltipClass="evidence-tooltip"
>
gavel
</mat-icon>
} @else {
<mat-icon
class="rule-decided-icon"
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
[class.decided-carried]="!rule.directlyDecided"
[matTooltip]="
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
"
matTooltipClass="evidence-tooltip"
>
{{
!rule.directlyDecided
? 'remove_circle_outline'
: rule.disposition === 'ESCALATE'
? 'report'
: 'verified'
}}
</mat-icon>
}
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{
severityLabel(rule.riskCategory)
}}</span>
<div class="detail-rule-text">
<span class="detail-rule-code">
{{ rule.ruleCode }}
@if (rule.governing) {
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule"
>governing</span
>
}
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
transaction being unreported. -->
@if (!isGradedRule(rule)) {
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
→ CTR filing
</span>
}
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
because an audit of the finding turns on which one they put their name to. -->
@if (rule.disposition) {
<span
class="rule-verdict"
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
[class.verdict-carried]="!rule.directlyDecided"
[matTooltip]="
rule.directlyDecided
? ruleDispositionLabel(rule) + (rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
"
>
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
</span>
}
</span>
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
</div>
</div>
}
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
clearing five of six rules leaves the case open on the sixth. -->
@if (selectedRuleCount > 0) {
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<span class="rule-action-count"
>{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span
>
<span class="bulk-spacer"></span>
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
<button mat-stroked-button color="primary" type="button" (click)="reviewSelectedRules(alert, 'WHITELIST')">
Whitelist
</button>
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
Suspicious
</button>
</div>
}
<div class="detail-foot">
@if (isHighSeverity(alert)) {
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
} @else if (!alert.highestSeverity) {
A statutory return rather than a graded suspicion — decided on its own.
} @else {
Every rule here is low or medium, so this transaction can be decided together with others.
}
</div>
</div>
}
</div>
</ng-template>
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
paginator before the data source has bound to it leaves that 0 in force, which pages every row
out of the table. -->
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
max-width: 100%;
overflow-x: hidden;
}
.flagged-toolbar {
display: flex;
align-items: center;
@@ -744,11 +750,13 @@
}
.actions-cell {
white-space: nowrap;
white-space: normal;
}
.action-group {
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 2px;
}
@@ -1011,3 +1019,21 @@
color: #1e293b;
font-variant-numeric: tabular-nums;
}
/* The rules popup. Its content is the same markup the inline pane used, so it only needs room and a close button. */
.rules-dialog {
position: relative;
padding: 8px 0 0;
.detail-inner {
padding: 18px 24px 22px;
border-bottom: none;
}
}
.rules-dialog-close {
position: absolute;
top: 4px;
right: 8px;
z-index: 1;
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild, TemplateRef } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatIconModule } from '@angular/material/icon';
@@ -167,9 +167,24 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
* 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.
this.expandedId = alert.id;
// Ticks belong to the alert they were made on, not to whichever popup is open next.
this.selectedRuleCodes.clear();
this.dialog
.open(this.rulesDialogTpl, { width: '780px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false })
.afterClosed()
.subscribe(() => {
this.expandedId = null;
this.selectedRuleCodes.clear();
});
}
/** The popup that lists every rule the open alert tripped. */
@ViewChild('rulesDialog') rulesDialogTpl!: TemplateRef<unknown>;
/** The alert the popup is showing, looked up fresh so a feed refresh behind it does not leave it stale. */
get expandedAlert(): any {
return this.alerts.data.find((a: any) => a.id === this.expandedId) ?? null;
}
isExpanded(alert: any): boolean {
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
max-width: 100%;
overflow-x: hidden;
}
.ra-intro {
display: flex;
align-items: flex-start;
@@ -58,6 +58,38 @@
</span>
</div>
<!-- A newly added rule is on trial, listed first, until an officer marks it normal -->
<div class="period-filters">
<span class="period-filters-label">Status</span>
<button
*ngFor="let s of statusFilters"
type="button"
class="period-chip"
[class.active]="activeStatus === s.key"
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
(click)="setStatus(s.key)"
>
{{ s.label }}
<span class="period-chip-n">{{ statusCount(s.key) }}</span>
</button>
</div>
<!-- Separate the rules by the kind of account they watch -->
<div class="period-filters">
<span class="period-filters-label">Account</span>
<button
*ngFor="let a of accountFilters"
type="button"
class="period-chip"
[class.active]="activeAccount === a.key"
[disabled]="accountCount(a.key) === 0 && activeAccount !== a.key"
(click)="setAccount(a.key)"
>
{{ a.label }}
<span class="period-chip-n">{{ accountCount(a.key) }}</span>
</button>
</div>
<!-- Separate the rules by the window they are measured over -->
<div class="period-filters">
<span class="period-filters-label">Applies over</span>
@@ -84,7 +116,15 @@
<!-- Rule Name Column -->
<ng-container matColumnDef="ruleName">
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
<td mat-cell *matCellDef="let element" class="rule-name-cell">
{{ element.ruleName }}
<span
*ngIf="element.onTrial"
class="trial-badge"
[title]="'On trial since ' + (element.trialStartedOn | date: 'dd MMM yyyy')"
>TRIAL · day {{ trialDays(element) + 1 }}</span
>
</td>
</ng-container>
<!-- Risk Category Column -->
@@ -163,8 +203,24 @@
</td>
</ng-container>
<!-- Trial: a newly added rule is on trial until an officer marks it normal -->
<ng-container matColumnDef="trial">
<th mat-header-cell *matHeaderCellDef class="col-center">Trial</th>
<td mat-cell *matCellDef="let element" class="col-center">
<button *ngIf="element.onTrial" mat-stroked-button type="button" (click)="markNormal(element)">
Mark as normal
</button>
<span *ngIf="!element.onTrial" class="trial-normal">Normal</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
class="rule-row"
[class.rule-row--trial]="row.onTrial"
></tr>
</table>
<!-- Mat Paginator for standard pagination -->
@@ -203,6 +259,15 @@
</div>
</ng-template>
</mat-tab>
<!-- How much each CC account has taken in and paid out. Lazily instantiated so it only loads when opened. -->
<mat-tab label="CC Accounts">
<ng-template matTabContent>
<div class="aml-tab-body">
<mifosx-aml-cc-tracking></mifosx-aml-cc-tracking>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
</mat-card-content>
</mat-card>
@@ -78,6 +78,7 @@
.aml-card-content {
padding: 24px;
overflow-x: hidden;
}
/* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the
@@ -86,10 +87,25 @@
::ng-deep .mat-mdc-tab-body-wrapper {
padding-top: 4px;
}
/* Material makes each tab body a horizontal scroller, so the few pixels the tables overshoot by showed up as a
scrollbar under every tab. The tables are width: 100% and their cells wrap, so nothing real sits out there. */
::ng-deep .mat-mdc-tab-body-content {
overflow-x: hidden;
}
::ng-deep .mat-mdc-tab-body-wrapper,
::ng-deep .mat-mdc-tab-body {
max-width: 100%;
}
}
.aml-tab-body {
padding-top: 16px;
/* The tab's own box, in case something inside it still overshoots by a few pixels. */
overflow-x: hidden;
max-width: 100%;
}
.aml-table {
@@ -426,3 +442,26 @@
font-size: 13px;
color: #475569;
}
/* A rule on trial: listed first, tinted, with a badge, until an officer marks it normal. */
.trial-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
white-space: nowrap;
}
.rule-row--trial {
background: #fffbeb;
}
.trial-normal {
color: #94a3b8;
font-size: 12px;
}
@@ -28,6 +28,7 @@ import { AlertService } from '../../core/alert/alert.service';
import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component';
import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component';
import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component';
import { AmlCcTrackingComponent } from './aml-cc-tracking/aml-cc-tracking.component';
@Component({
selector: 'mifosx-aml-rules',
@@ -49,7 +50,8 @@ import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurrin
MatTabsModule,
FormsModule,
AmlFlaggedTransactionsComponent,
AmlRecurringAlertsComponent
AmlRecurringAlertsComponent,
AmlCcTrackingComponent
]
})
export class AmlRulesComponent implements OnInit {
@@ -68,7 +70,8 @@ export class AmlRulesComponent implements OnInit {
'appliesOver',
'reason',
'amlCheckStatus',
'bannedEntityMatch'
'bannedEntityMatch',
'trial'
];
/** Every rule as returned by the API, before the period filter is applied. */
@@ -85,6 +88,26 @@ export class AmlRulesComponent implements OnInit {
];
activePeriod = '';
/**
* Which kind of account the rules are for. CC is a Cash Credit account: a current account allowed to draw an
* overdraft against a sanctioned limit. The chip lists every scenario that watches such an account, whatever its
* coverage status, so the officer sees what is and is not yet being checked.
*/
readonly accountFilters: { key: string; label: string }[] = [
{ key: '', label: 'All accounts' },
{ key: 'CC', label: 'CC' }
];
activeAccount = '';
/** Whether to list only the rules still on trial. */
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All rules' },
{ key: 'TRIAL', label: 'On trial' }
];
activeStatus = '';
private statusCounts: Record<string, number> = {};
private accountCounts: Record<string, number> = {};
/** Free-text search, applied on top of the period filter. */
searchTerm = '';
@@ -108,7 +131,15 @@ export class AmlRulesComponent implements OnInit {
private loadRules(): void {
this.clientsService.getClientRiskRules().subscribe({
next: (data) => {
this.allRules = data || [];
// A rule on trial is listed first, the newest first; the rest keep the catalog order.
const all: any[] = data || [];
const trial = all
.filter((r) => r.onTrial)
.sort((a, b) => String(b.trialStartedOn || '').localeCompare(String(a.trialStartedOn || '')) || b.id - a.id);
this.allRules = [
...trial,
...all.filter((r) => !r.onTrial)
];
this.applyPeriodFilter();
this.rules.paginator = this.paginator;
},
@@ -145,6 +176,48 @@ export class AmlRulesComponent implements OnInit {
this.applyPeriodFilter();
}
setStatus(key: string): void {
this.activeStatus = key;
this.applyPeriodFilter();
}
/** How many rules are on each status chip — shown on the chips. */
statusCount(key: string): number {
return this.statusCounts[key] ?? 0;
}
/** Ends a rule's trial: it stays in the catalog as a normal rule. */
markNormal(rule: any): void {
this.clientsService.markClientRiskRuleNormal(rule.id).subscribe({
next: () => {
this.alertService.alert({ type: 'AML Rule', message: `"${rule.ruleName}" is now a normal rule.` });
this.loadRules();
}
});
}
/** Days a rule has been on trial, from the date it was added. */
trialDays(rule: any): number {
if (!rule?.trialStartedOn) return 0;
const started = new Date(String(rule.trialStartedOn));
return Math.max(0, Math.floor((Date.now() - started.getTime()) / 86400000));
}
setAccount(key: string): void {
this.activeAccount = key;
this.applyPeriodFilter();
}
/** How many rules belong to each account chip — shown on the chips. */
accountCount(key: string): number {
return this.accountCounts[key] ?? 0;
}
/** Whether a rule is for the account kind the officer has picked. */
private inAccountScope(rule: any): boolean {
return !this.activeAccount || (this.activeAccount === 'CC' && !!rule.ccRelevant);
}
applySearch(value: string): void {
this.searchTerm = value;
this.rules.filter = value.trim().toLowerCase();
@@ -171,9 +244,12 @@ export class AmlRulesComponent implements OnInit {
}
private applyPeriodFilter(): void {
this.rules.data = this.activePeriod
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
: this.allRules;
this.rules.data = this.allRules.filter(
(r) =>
this.inAccountScope(r) &&
(!this.activeStatus || !!r.onTrial) &&
(!this.activePeriod || this.periodKey(r) === this.activePeriod)
);
this.recomputePeriodCounts();
if (this.rules.paginator) {
this.rules.paginator.firstPage();
@@ -191,7 +267,12 @@ export class AmlRulesComponent implements OnInit {
*/
private recomputePeriodCounts(): void {
const filter = this.rules.filter;
const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
const searched = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
// The account chips count within the search and the chosen period; the period chips within the chosen account.
const inPeriod = searched.filter((r) => !this.activePeriod || this.periodKey(r) === this.activePeriod);
this.accountCounts = { '': inPeriod.length, CC: inPeriod.filter((r) => !!r.ccRelevant).length };
this.statusCounts = { '': searched.length, TRIAL: searched.filter((r) => !!r.onTrial).length };
const pool = searched.filter((r) => this.inAccountScope(r) && (!this.activeStatus || !!r.onTrial));
this.periodCounts = { '': pool.length };
for (const p of this.periodFilters) {
if (p.key) {