Merge latest feature/advanceLoanProduct into feature/merge-dds-advance-loan-into-dev-latest

Brings Sonali's four new commits (7-12 Oct):
- Loans A/C (EMI) stepper wired to the loan interest rate register
- Maker-checker UI for Delinquency Ranges and Buckets
- GL mapping, checker diff view and approval flow for the loan interest
  rate register
- Her merge of dev-latest (7 Oct)

Conflicts resolved:
- src/assets/env.js, src/environments/.env.ts: kept dev-latest (all
  seven tenants; .env.ts is regenerated by `npm start`).

Hidden issue handled (no Git conflict, but keshav's work would have
been overwritten):
Sonali's merge commit a5a64d4ef carried its own changes to 19 of
keshav's non-loan files (TDS reports, RBI reserve return / Form 1,
dashboard and CRR-SLR dialog, Form 121 tab, RBI reserve config,
risk review). keshav changed two of them again on 12 Oct
(2d2eac8d0). These 19 files were re-merged three-way against keshav's
7 Oct version (6b0f218b7), the version Sonali merged from:
- every line keshav added after 7 Oct is kept;
- every line Sonali added is kept;
- about 130 of keshav's 7 Oct lines that Sonali edited inside her merge
  commit now carry her edit. No conflicts.
Files: tds-interest-export(.spec).ts, tds-interest-report.component
(.html/.ts), tds-remittance-report.component(.html/.ts),
tds-reports.compute(.spec).ts, tds-reports.service.ts,
reserve-return.base.ts, reserve-return.component.html,
form1-statutory.component.scss, rbi-reserve-config.component.html,
dashboard.component(.html/.ts), crr-slr-dialog.component(.html/.ts),
form121-tab.component.ts, risk-review-report.component.scss.

Prettier formatting on rbi-reserve-config.component.html and
reserve-return.component.html after the merge (content unchanged).

Nothing from dev-latest, DDS, keshav or Sonali is removed. Verified
with tsc -p src/tsconfig.app.json --noEmit, prettier, stylelint,
eslint and the license header check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:29:09 +05:30
co-authored by Claude Opus 5.5
85 changed files with 5520 additions and 1166 deletions
@@ -343,10 +343,13 @@ export class CheckerInboxComponent implements OnInit {
// resourceId is absent (CREATE rows: entity does not exist yet until approved),
// or entityName is unmapped — fall back to the detail dialog so the checker
// can still view the command payload and action the entry.
// A Loan Base Interest Rate request carries a 10-column rate chart, which needs a wide dialog.
const wide = (row.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE';
this.dialog
.open(ViewCheckerInboxComponent, {
data: { checkerId: row.id },
autoFocus: false
autoFocus: false,
...(wide ? { width: '1200px', maxWidth: '95vw' } : {})
})
.afterClosed()
.subscribe((result: any) => {
@@ -0,0 +1,169 @@
/**
* 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 { ChangeKind, CommandChange } from './command-diff.util';
/** Entity name the maker-checker queue uses for Loan Base Interest Rate submissions. */
export const LOAN_BASE_INTEREST_RATE_ENTITY = 'LOANBASEINTERESTRATE';
export function isLoanBaseInterestRateCreate(entityName?: string | null, actionName?: string | null): boolean {
return (
(entityName ?? '').toUpperCase() === LOAN_BASE_INTEREST_RATE_ENTITY && (actionName ?? '').toUpperCase() === 'CREATE'
);
}
const HEADER_FIELDS: { key: string; label: string }[] = [
{ key: 'name', label: 'Name' },
{ key: 'effectiveFromDate', label: 'Effective From Date' },
{ key: 'currencyCode', label: 'Currency Code' },
{ key: 'fallbackNoCibilSlab', label: 'Fallback No CIBIL Slab' },
{ key: 'fallbackBelowMin', label: 'Fallback Below Min' },
{ key: 'note', label: 'Note' }
];
const NA = 'N.A.';
const EMPTY = '—';
/** One chart cell as the checker reads it: a % rate, a formula, or N.A. */
function cellText(cell: any): string {
if (!cell || cell.isNa) {
return NA;
}
if (cell.isFormula && cell.rateFormula) {
return String(cell.rateFormula);
}
return cell.rateValue === null || cell.rateValue === undefined ? NA : `${Number(cell.rateValue)}%`;
}
function plain(value: any): string {
return value === null || value === undefined || value === '' ? EMPTY : String(value);
}
function kindOf(oldText: string, newText: string, emptyTexts: string[]): ChangeKind {
const oldEmpty = emptyTexts.includes(oldText);
const newEmpty = emptyTexts.includes(newText);
if (oldEmpty && !newEmpty) {
return 'ADDED';
}
if (!oldEmpty && newEmpty) {
return 'REMOVED';
}
return 'MODIFIED';
}
/**
* Compares a submitted Loan Base Interest Rate version against the version currently in force and
* returns only the values that differ: header fields, individual rate cells (per loan type and
* amount slab) and CIBIL adjustment bands.
*/
export function diffLoanRateRegister(submitted: any, active: any): CommandChange[] {
const changes: CommandChange[] = [];
for (const { key, label } of HEADER_FIELDS) {
if (submitted?.[key] === undefined) {
continue;
}
const oldText = plain(active?.[key]);
const newText = plain(submitted[key]);
if (oldText !== newText) {
changes.push({ label, kind: kindOf(oldText, newText, [EMPTY]), oldValue: oldText, newValue: newText });
}
}
// Rate cells, keyed by loan type + slab order.
const cellKey = (c: any) => `${c.productSrNo}|${c.slabOrder}`;
const oldCells = new Map<string, any>(
(active?.slabs ?? []).map((c: any): [string, any] => [
cellKey(c),
c
])
);
const newCells = new Map<string, any>(
(submitted?.slabs ?? []).map((c: any): [string, any] => [
cellKey(c),
c
])
);
const names = new Map<number, string>();
for (const c of active?.slabs ?? []) {
if (c.productName) {
names.set(c.productSrNo, c.productName);
}
}
for (const p of submitted?.products ?? []) {
names.set(p.srNo, p.name);
}
const allKeys = new Set<string>([
...newCells.keys(),
...oldCells.keys()
]);
const cellChanges = [...allKeys]
.map((k) => {
const before = oldCells.get(k);
const after = newCells.get(k);
const ref = after ?? before;
return {
srNo: Number(ref.productSrNo),
slabOrder: Number(ref.slabOrder),
label: `${names.get(ref.productSrNo) ?? `Loan type ${ref.productSrNo}`} — ${ref.slabLabel ?? `Slab ${ref.slabOrder + 1}`}`,
oldText: before ? cellText(before) : EMPTY,
newText: after ? cellText(after) : EMPTY
};
})
// A cell that is N.A. in the submission and was never in the old version is not a change.
.filter((c) => c.oldText !== c.newText && !(c.oldText === EMPTY && c.newText === NA))
.sort((a, b) => a.srNo - b.srNo || a.slabOrder - b.slabOrder);
for (const c of cellChanges) {
changes.push({
label: c.label,
kind: kindOf(c.oldText, c.newText, [
EMPTY,
NA
]),
oldValue: c.oldText,
newValue: c.newText
});
}
// CIBIL bands, matched by their score range, so a re-ordered list is not reported as a change.
const bandKey = (b: any) => `${b.scoreFrom}-${b.scoreTo}`;
const bandText = (b: any) => `${Number(b.adjustmentPct)}% (${b.slabType ?? 'Flat'})`;
const oldBands = new Map<string, any>(
(active?.cibilSlabs ?? []).map((b: any): [string, any] => [
bandKey(b),
b
])
);
const newBands = new Map<string, any>(
(submitted?.cibilSlabs ?? []).map((b: any): [string, any] => [
bandKey(b),
b
])
);
for (const k of new Set<string>([
...newBands.keys(),
...oldBands.keys()
])) {
const before = oldBands.get(k);
const after = newBands.get(k);
const oldText = before ? bandText(before) : EMPTY;
const newText = after ? bandText(after) : EMPTY;
if (oldText !== newText) {
const ref = after ?? before;
changes.push({
label: `CIBIL ${ref.scoreFrom}–${ref.scoreTo}${ref.tier ? ` (${ref.tier})` : ''} — Adjustment`,
kind: kindOf(oldText, newText, [EMPTY]),
oldValue: oldText,
newValue: newText
});
}
}
return changes;
}
@@ -63,6 +63,9 @@ const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Ob
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true),
// SMA / NPA classification master. The bucket is read as-is; the dialog resolves its range ids to names.
DELINQUENCY_RANGE: (id, s) => s.products.getDelinquencyRange(id),
DELINQUENCY_BUCKET: (id, s) => s.products.getDelinquencyBucket(id),
// ---- Accounts ----------------------------------------------------------------
SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id),
@@ -199,6 +202,12 @@ const DELETE_SUMMARY_KEYS: Record<string, string[]> = {
'email',
'officeName'
],
DELINQUENCY_RANGE: [
'classification',
'minimumAgeDays',
'maximumAgeDays'
],
DELINQUENCY_BUCKET: ['name'],
GLACCOUNT: [
'name',
'glCode',
@@ -6,7 +6,7 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="vci" [class.vci--dialog]="isDialog">
<div class="vci" [class.vci--dialog]="isDialog" [class.vci--wide]="!!rateDiff">
<!-- The heading lives here in both modes, so a dialog does not end up showing it twice. -->
<div class="vci-head">
<span class="vci-head__title">{{ headingTitle }}</span>
@@ -114,10 +114,30 @@
</div>
}
<!-- Loan Base Interest Rate: the same chart view as the Compare screen, before the change list. -->
@if (rateDiff) {
<div class="vci-section">
<div class="vci-section__title">Rate Chart Comparison</div>
@if (changesBaseline) {
<p class="vci-nochange">{{ changesBaseline }}</p>
}
<mifosx-rate-register-diff
[slabs]="rateDiff.slabs"
[draftRows]="rateDiff.draftRows"
[baselineRows]="rateDiff.baselineRows"
[draftCibil]="rateDiff.draftCibil"
[baselineCibil]="rateDiff.baselineCibil"
></mifosx-rate-register-diff>
</div>
}
<!-- UPDATE: only the fields this command actually moves, as OLD -> NEW. -->
@if (showChanges) {
<div class="vci-section">
<div class="vci-section__title">Changes Requested ({{ changes.length }})</div>
@if (changesBaseline) {
<p class="vci-nochange">{{ changesBaseline }}</p>
}
@if (changes.length === 0) {
<p class="vci-nochange">This request does not change any value.</p>
} @else {
@@ -143,7 +163,7 @@
}
<!-- CREATE: submitted data grouped into readable sections. -->
@if (!showChanges && createSections.length > 0) {
@if (!showChanges && !rateDiff && !loanRateLoading && createSections.length > 0) {
@for (section of createSections; track section.title) {
<div class="vci-section">
<div class="vci-section__title">{{ section.title }}</div>
@@ -157,7 +177,7 @@
}
<!-- DELETE summary, role-permission changes, and the fallback payload all render here. -->
@if (!showChanges && createSections.length === 0 && displayJSONData) {
@if (!showChanges && !rateDiff && !loanRateLoading && createSections.length === 0 && displayJSONData) {
<div class="vci-section">
<div class="vci-section__title">
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
@@ -32,6 +32,11 @@ $bg-page: #f8fafc;
border-radius: 10px;
}
/* A wide payload (the Loan Base Interest Rate chart) fills the dialog the caller sized for it. */
&--dialog#{&}--wide {
width: 100%;
}
&:not(&--dialog) {
max-width: 900px;
margin: 20px auto;
@@ -10,6 +10,7 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import * as _ from 'lodash';
import { forkJoin } from 'rxjs';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
/** Custom Services */
@@ -31,6 +32,7 @@ import {
groupCreateFields,
humanizeKey
} from '../command-diff.util';
import { diffLoanRateRegister, isLoanBaseInterestRateCreate } from '../loan-rate-register-diff.util';
import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch';
/** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */
@@ -42,6 +44,22 @@ import { UsersService } from 'app/users/users.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { OrganizationService } from 'app/organization/organization.service';
import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service';
import { RateRegisterDiffComponent } from 'app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component';
import {
CibilSlab,
RateChartRow,
SlabColumn
} from 'app/products/loan-interest-rate-register/models/rate-register.model';
/** Inputs of the shared rate-register comparison view (see RateRegisterDiffComponent). */
interface RateDiffView {
slabs: SlabColumn[];
draftRows: RateChartRow[];
baselineRows: RateChartRow[];
draftCibil: CibilSlab[];
baselineCibil: CibilSlab[];
}
/**
* One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of
@@ -132,7 +150,8 @@ const ENTRY_LABEL_KEYS = [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
NgTemplateOutlet,
DateFormatPipe
DateFormatPipe,
RateRegisterDiffComponent
]
})
export class ViewCheckerInboxComponent {
@@ -150,6 +169,7 @@ export class ViewCheckerInboxComponent {
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private organizationService = inject(OrganizationService);
private rateRegisterService = inject(RateRegisterService);
/** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */
private get entityFetchServices(): EntityFetchServices {
@@ -196,10 +216,22 @@ export class ViewCheckerInboxComponent {
/** UPDATE review: the fields this command actually moves, as OLD -> NEW. */
changes: CommandChange[] = [];
/** Note naming the version a Loan Base Interest Rate request is compared against. */
changesBaseline = '';
/** Loan Base Interest Rate request: the chart-style comparison (same view as the Compare screen). */
rateDiff: RateDiffView | null = null;
/** True once the current entity was read and a real comparison is on screen. */
showChanges = false;
/** True while the current entity is being fetched for comparison. */
comparing = false;
/** A Loan Base Interest Rate request still waiting for its chart: hides the raw payload meanwhile. */
get loanRateLoading(): boolean {
return (
this.comparing &&
isLoanBaseInterestRateCreate(this.checkerInboxDetail?.entityName, this.checkerInboxDetail?.actionName)
);
}
/** True when the screen is describing a record that is about to be deleted. */
deletingEntity = false;
/** CREATE review: submitted data bucketed into readable sections. */
@@ -354,6 +386,8 @@ export class ViewCheckerInboxComponent {
this.comparing = false;
this.deletingEntity = false;
this.createSections = [];
this.changesBaseline = '';
this.rateDiff = null;
// commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined)
// throws — which as a route only broke the page, but in a dialog would leave an empty shell open.
try {
@@ -377,10 +411,16 @@ export class ViewCheckerInboxComponent {
} else if (action === 'DELETE') {
this.loadDeleteSummary(detail);
} else if (action === 'CREATE') {
if (isLoanBaseInterestRateCreate(detail?.entityName, detail?.actionName)) {
this.loadLoanRateRegisterChanges(detail);
}
this.createSections = this.buildCreateSections(this.jsondata);
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
this.resolveGlAccountCreateLabels(detail, this.jsondata);
}
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
this.resolveDelinquencyBucketCreateRanges(detail);
}
}
}
@@ -393,6 +433,10 @@ export class ViewCheckerInboxComponent {
* entity showed before.
*/
private loadUpdateChanges(detail: any): void {
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
this.loadDelinquencyBucketChanges(detail);
return;
}
const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
if (!request$) {
return;
@@ -415,6 +459,131 @@ export class ViewCheckerInboxComponent {
});
}
/** One line per range for a delinquency bucket, e.g. 'SMA-1 (31 - 60 days)', oldest range first. */
private delinquencyRangeNames(ids: any[], rangesById: Map<number, any>): string {
return (ids ?? [])
.map((entry) => rangesById.get(Number(entry?.id ?? entry)) ?? { id: Number(entry?.id ?? entry) })
.sort((a, b) => (a.minimumAgeDays ?? 0) - (b.minimumAgeDays ?? 0))
.map((range) =>
range.classification
? `${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)`
: `Range #${range.id}`
)
.join(', ');
}
/**
* Delinquency bucket UPDATE: the payload lists range ids, which mean nothing to a checker, and the stored
* bucket lists range objects, so a plain field diff would show two unreadable lists. Both sides are turned
* into the same readable range names first, so only a real difference is reported as OLD -> NEW.
*/
private loadDelinquencyBucketChanges(detail: any): void {
this.comparing = true;
forkJoin({
current: this.productsService.getDelinquencyBucket(detail?.resourceId),
ranges: this.productsService.getDelinquencyRanges()
}).subscribe({
next: ({ current, ranges }: { current: any; ranges: any }) => {
if (!this.isCurrent(detail)) {
return;
}
this.comparing = false;
const rangesById = new Map<number, any>(
(ranges ?? []).map((range: any) => [
Number(range.id),
range
])
);
const submitted = {
...(this.jsondata ?? {}),
ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById)
};
const existing = { ...(current ?? {}), ranges: this.delinquencyRangeNames(current?.ranges, rangesById) };
this.changes = diffCommandAgainstEntity(submitted, existing);
this.showChanges = true;
},
error: () => {
this.comparing = false;
}
});
}
/** Delinquency bucket CREATE: show the chosen ranges by name instead of their ids. */
private resolveDelinquencyBucketCreateRanges(detail: any): void {
this.productsService.getDelinquencyRanges().subscribe({
next: (ranges: any) => {
if (!this.isCurrent(detail)) {
return;
}
const rangesById = new Map<number, any>(
(ranges ?? []).map((range: any) => [
Number(range.id),
range
])
);
const resolved = {
...(this.jsondata ?? {}),
ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById)
};
this.createSections = this.buildCreateSections(resolved);
this.commandFields = this.buildCommandFields(resolved);
}
});
}
/**
* Loan Base Interest Rate: every submission is a CREATE of a whole new version (16 loan types x 10
* slabs plus CIBIL bands), so the raw payload hides the handful of rates the maker actually moved.
* Compares it with the version in force today and shows only the differences as OLD -> NEW. With no
* active version (first ever submission) the grouped CREATE view stays on screen.
*/
private loadLoanRateRegisterChanges(detail: any): void {
this.comparing = true;
this.productsService.getActiveLoanBaseInterestRate().subscribe({
next: (active: any) => {
if (!this.isCurrent(detail)) {
return;
}
this.comparing = false;
this.changes = diffLoanRateRegister(this.jsondata ?? {}, active ?? {});
this.rateDiff = this.buildRateDiff(detail, active);
if (active) {
const effective = active.effectiveFromDate ? `, effective ${active.effectiveFromDate}` : '';
this.changesBaseline = `Compared with active version: ${active.name ?? 'Version ' + active.id}${effective}`;
} else {
this.changesBaseline =
'No active version exists yet - this is the first version, so every filled value is new.';
}
// The chart comparison already shows every change, so the flat list is only the fallback for a
// payload the chart could not be built from.
this.showChanges = !this.rateDiff;
},
error: () => {
this.comparing = false;
}
});
}
/**
* The chart-style view of a Loan Base Interest Rate request: the submitted version against the active
* one (or against a blank baseline when there is no active version yet). Null if the payload cannot be
* read, in which case only the change list is shown.
*/
private buildRateDiff(detail: any, active: any): RateDiffView | null {
const submitted = this.rateRegisterService.registerFromPendingEntry(detail);
if (!submitted) {
return null;
}
const base = active ? this.rateRegisterService.registerFromServerDto(active) : null;
return {
slabs: submitted.slabs,
draftRows: submitted.rows,
baselineRows: base ? base.rows : submitted.baselineRows,
draftCibil: submitted.cibilSlabs,
baselineCibil: base ? base.cibilSlabs : submitted.baselineCibilSlabs
};
}
/**
* DELETE: the payload is empty for every entity, so the command alone tells the checker nothing.
* Reads the record instead and shows the few fields that identify it.