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
@@ -321,7 +321,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
this.availableFys = [...fys].sort().reverse(); this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) { if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? ''; this.fy =
this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
} }
} }
@@ -367,9 +368,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
: 0; : 0;
} }
// What was actually debited for this customer as deductee, wherever the savings account sits. // What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0);
.filter((a) => a.meta.tdsApplicable)
.reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth || this.configMissing) { if (!this.dateOfBirth || this.configMissing) {
return; return;
@@ -74,9 +74,7 @@
(3%)" while Admin holds 4% states a rate the bank is not on. --> (3%)" while Admin holds 4% states a rate the bank is not on. -->
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div> <div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div> <div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub"> <div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
</div>
</div> </div>
</div> </div>
@@ -143,21 +141,22 @@
</span> </span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="3"><strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong></td> <td colspan="3">
<td class="right foot-demand">{{ totalOf(demandLiabilities, "openingValue") }}</td> <strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong>
<td class="right foot-demand">{{ totalOf(demandLiabilities, "currentValue") }}</td> </td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, 'openingValue') }}</td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, 'currentValue') }}</td>
<td class="right">{{ totalChange(demandLiabilities) }}</td> <td class="right">{{ totalChange(demandLiabilities) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -206,21 +205,22 @@
</span> </span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="3"><strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong></td> <td colspan="3">
<td class="right foot-time">{{ totalOf(timeLiabilities, "openingValue") }}</td> <strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong>
<td class="right foot-time">{{ totalOf(timeLiabilities, "currentValue") }}</td> </td>
<td class="right foot-time">{{ totalOf(timeLiabilities, 'openingValue') }}</td>
<td class="right foot-time">{{ totalOf(timeLiabilities, 'currentValue') }}</td>
<td class="right">{{ totalChange(timeLiabilities) }}</td> <td class="right">{{ totalChange(timeLiabilities) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -263,12 +263,11 @@
<span class="status-chip chip-active">{{ item.status }}</span> <span class="status-chip chip-active">{{ item.status }}</span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
@@ -278,8 +277,8 @@
<td colspan="3"> <td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span> <strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
</td> </td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "openingValue") }}</td> <td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "currentValue") }}</td> <td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(bankingAssets) }}</td> <td class="right">{{ totalChange(bankingAssets) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -328,12 +327,11 @@
<span class="status-chip chip-active">{{ item.status }}</span> <span class="status-chip chip-active">{{ item.status }}</span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
@@ -341,8 +339,8 @@
<tfoot> <tfoot>
<tr> <tr>
<td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td> <td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "openingValue") }}</td> <td class="right foot-slr">{{ totalOf(liquidAssets, 'openingValue') }}</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "currentValue") }}</td> <td class="right foot-slr">{{ totalOf(liquidAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(liquidAssets) }}</td> <td class="right">{{ totalChange(liquidAssets) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -128,8 +128,7 @@ export class CrrSlrDialogComponent implements OnInit {
if (!columns.length) { if (!columns.length) {
this.unavailable = this.unavailable =
data?.unmetRequirements?.[0] ?? data?.unmetRequirements?.[0] ?? 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
return; return;
} }
// The return computes but says something would make it wrong to file — a rate never set, a // The return computes but says something would make it wrong to file — a rate never set, a
@@ -175,7 +174,6 @@ export class CrrSlrDialogComponent implements OnInit {
}); });
} }
/** /**
* The four tabs, filled from the return's own lines. * The four tabs, filled from the return's own lines.
* *
@@ -33,11 +33,15 @@
rate Admin was on. --> rate Admin was on. -->
<div class="card-metrics"> <div class="card-metrics">
<div class="metric-row"> <div class="metric-row">
<span class="metric-label">CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span> <span class="metric-label"
>CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.crrRequired }}</span> <span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div> </div>
<div class="metric-row"> <div class="metric-row">
<span class="metric-label">SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span> <span class="metric-label"
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.slrRequired }}</span> <span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div> </div>
</div> </div>
@@ -116,7 +116,12 @@ export class DashboardComponent implements OnInit {
const crrNeed = data?.partB?.required?.[latest]; const crrNeed = data?.partB?.required?.[latest];
const slrHeld = data?.partC?.maintained?.[latest]; const slrHeld = data?.partC?.maintained?.[latest];
const slrNeed = data?.partC?.required?.[latest]; const slrNeed = data?.partC?.required?.[latest];
if ([crrHeld, crrNeed, slrHeld, slrNeed].every((v) => v !== null && v !== undefined)) { if ([
crrHeld,
crrNeed,
slrHeld,
slrNeed
].every((v) => v !== null && v !== undefined)) {
this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short'; this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short';
} }
}, },
@@ -0,0 +1,43 @@
<!--
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/.
-->
@if (product) {
<div class="lptc">
<div class="lptc__head">
<strong>{{ product.name }}</strong>
<span class="lptc__chip">{{ category }}</span>
<span class="lptc__chip lptc__chip--type">{{ type }}</span>
</div>
<div class="lptc__grid">
<div>
<span class="lptc__label">Amount</span>
<span>
@if (product.minPrincipal != null || product.maxPrincipal != null) {
{{ product.minPrincipal | number: '1.0-0' }} – {{ product.maxPrincipal | number: '1.0-0' }}
} @else {
—
}
</span>
</div>
<div>
<span class="lptc__label">Tenure (repayments)</span>
<span>
@if (product.minNumberOfRepayments != null || product.maxNumberOfRepayments != null) {
{{ product.minNumberOfRepayments }} – {{ product.maxNumberOfRepayments }}
} @else {
—
}
</span>
</div>
<div>
<span class="lptc__label">Short name</span>
<span>{{ product.shortName || '—' }}</span>
</div>
</div>
</div>
}
@@ -0,0 +1,52 @@
/**
* 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/.
*/
.lptc {
margin: 4px 0 16px;
padding: 12px 16px;
border: 1px solid #bae6fd;
border-radius: 8px;
background: #f0f9ff;
&__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__chip {
padding: 1px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 600;
&--type {
background: #ede9fe;
color: #5b21b6;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 16px;
font-size: 13px;
color: #1e293b;
}
&__label {
display: block;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
}
@@ -0,0 +1,37 @@
/**
* 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, Input } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { productCategoryLabel, productTypeLabel } from 'app/products/loan-products/loan-product-types';
/**
* The product picked on the loan account form, summarised with its category, type and the main limits, so the
* officer can see what the account will be opened under before going on.
*/
@Component({
selector: 'mifosx-loan-product-template-card',
templateUrl: './loan-product-template-card.component.html',
styleUrls: ['./loan-product-template-card.component.scss'],
imports: [DecimalPipe]
})
export class LoanProductTemplateCardComponent {
/** A product from the catalogue (the list shape), or undefined when none is picked. */
@Input() product: any;
get category(): string {
return productCategoryLabel(this.product?.advanceExtensions?.productTypeGroup);
}
get type(): string {
return productTypeLabel(
this.product?.advanceExtensions?.productTypeGroup,
this.product?.advanceExtensions?.productTypeSubGroup
);
}
}
@@ -32,6 +32,9 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
/** /**
* Loans Account Details Step * Loans Account Details Step
@@ -49,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatStepperPrevious, MatStepperPrevious,
FaIconComponent, FaIconComponent,
MatStepperNext, MatStepperNext,
AsyncPipe AsyncPipe,
LoanProductTemplateCardComponent
] ]
}) })
export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
@@ -59,6 +63,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private commons = inject(Commons); private commons = inject(Commons);
private catalog = inject(LoanProductCatalogService);
//** Defining PlaceHolders for the search bar */ //** Defining PlaceHolders for the search bar */
placeHolderLabel = ''; placeHolderLabel = '';
@@ -88,6 +93,27 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
loanId: any = null; loanId: any = null;
/** Category and product type narrow the product picker to the active products of that kind. */
readonly categories = PRODUCT_TYPE_GROUPS;
categoryFilter = '';
typeFilter = '';
/** Ids of the products active today; null until read, or if the read failed (then nothing is hidden). */
private activeProductIds: Set<number> | null = null;
/** Every product from the template, before any filter. */
private allProducts: any[] = [];
/** Types offered for the chosen category; every type when no category is chosen. */
get typeOptions() {
return this.categoryFilter
? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? [])
: Object.values(PRODUCT_SUB_TYPES).flat();
}
/** The product picked in the form, with its category and type, for the template card. */
get selectedProduct(): any {
return this.catalog.get(this.loansAccountDetailsForm?.get('productId')?.value);
}
loanProductSelected = false; loanProductSelected = false;
/** Currency data. */ /** Currency data. */
protected productData: ReplaySubject<string[]> = new ReplaySubject<string[]>(1); protected productData: ReplaySubject<string[]> = new ReplaySubject<string[]>(1);
@@ -115,7 +141,8 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.maxDate = this.settingsService.maxFutureDate; this.maxDate = this.settingsService.maxFutureDate;
this.buildDependencies(); this.buildDependencies();
if (this.loansAccountTemplate) { if (this.loansAccountTemplate) {
this.productList = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name')); this.allProducts = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name'));
this.productList = this.allProducts;
if (this.loansAccountTemplate.loanProductId) { if (this.loansAccountTemplate.loanProductId) {
this.loansAccountDetailsForm.patchValue({ this.loansAccountDetailsForm.patchValue({
productId: this.loansAccountTemplate.loanProductId, productId: this.loansAccountTemplate.loanProductId,
@@ -136,6 +163,44 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.searchItem(); this.searchItem();
}); });
this.productData.next(this.productList.slice()); this.productData.next(this.productList.slice());
this.loadActiveProducts();
}
/**
* A new account can only be opened on a product that is active today, and the picker can be narrowed by category
* and type. Editing an existing account keeps every product, since its own product may have closed since.
*/
private loadActiveProducts(): void {
if (this.loanId) {
return;
}
this.catalog.activeProducts().subscribe((products) => {
this.activeProductIds = products.length > 0 ? new Set(products.map((product) => Number(product.id))) : null;
this.applyProductFilters();
});
}
onCategoryChange(): void {
// A type belongs to one category, so a type from another category cannot stay selected.
if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) {
this.typeFilter = '';
}
this.applyProductFilters();
}
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
applyProductFilters(): void {
this.productList = this.allProducts.filter((product: any) => {
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
return false;
}
const ext = this.catalog.classification(product.id);
if (this.categoryFilter && ext['productTypeGroup'] !== this.categoryFilter) {
return false;
}
return !this.typeFilter || ext['productTypeSubGroup'] === this.typeFilter;
});
this.searchItem();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -0,0 +1,48 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
/**
* Read-only view of the loan product catalogue for the loan account screens: which products are active today and
* what category and type each one is. Reads the normal product list once; the category and type of each product
* come from the classification the list carries (`advanceExtensions`).
*/
@Injectable({
providedIn: 'root'
})
export class LoanProductCatalogService {
private productsService = inject(ProductsService);
private byId = new Map<number, any>();
/**
* The products that are active today. Also remembers every product it saw, so `get` and `classification` can
* answer for them afterwards. An empty list on failure, so the picker never ends up with nothing to choose.
*/
activeProducts(): Observable<any[]> {
return this.productsService.getLoanProducts().pipe(
tap((products: any[]) => (products ?? []).forEach((product) => this.byId.set(Number(product.id), product))),
map((products: any[]) => (products ?? []).filter((product) => product?.status === 'loanProduct.active')),
catchError(() => of([]))
);
}
/** A product seen by `activeProducts`, or undefined before it ran or for an unknown id. */
get(productId: any): any {
return this.byId.get(Number(productId));
}
/** The product's category and type (`productTypeGroup`, `productTypeSubGroup`, ...), empty when it has none. */
classification(productId: any): Record<string, any> {
return this.get(productId)?.advanceExtensions ?? {};
}
}
@@ -52,7 +52,9 @@
} }
} @else { } @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span> <span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span> <span class="rrc-rate-now__since"
>{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span
>
} }
</div> </div>
} }
@@ -230,7 +232,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- Tracked by the row's own id, never by position. With $index, deleting a row shifted every <!-- Tracked by the row's own id, never by position. With $index, deleting a row shifted every
row after it up one, Angular reused the inputs bound to the old positions, and row after it up one, Angular reused the inputs bound to the old positions, and
ngModel wrote their values back into the shortened array - the row reappeared and ngModel wrote their values back into the shortened array - the row reappeared and
Save stayed grey. --> Save stayed grey. -->
@@ -41,7 +41,12 @@ export class CibilAdjustmentComponent implements OnInit {
const idParam = this.route.snapshot.queryParamMap.get('id'); const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null; const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => { this.service.list().subscribe((all) => {
this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
if (!found) {
this.router.navigate(['/products/loan-interest-rate-register']);
return;
}
this.register = found;
this.active = this.service.getActive(); this.active = this.service.getActive();
}); });
} }
@@ -27,7 +27,7 @@
<div class="er__meta-bar"> <div class="er__meta-bar">
<div class="er__field"> <div class="er__field">
<label>REGISTER NAME</label> <label>REGISTER NAME</label>
<input type="text" [(ngModel)]="register.name" [readonly]="!canEdit" /> <input type="text" [(ngModel)]="register.name" [readonly]="!canEdit" placeholder="Entered when you submit" />
</div> </div>
<div class="er__field er__field--date"> <div class="er__field er__field--date">
<label>EFFECTIVE FROM</label> <label>EFFECTIVE FROM</label>
@@ -55,6 +55,9 @@
{{ rows.length }} products × {{ slabs.length }} amount slabs {{ rows.length }} products × {{ slabs.length }} amount slabs
<span *ngIf="canEdit">· click any cell to edit</span> <span *ngIf="canEdit">· click any cell to edit</span>
<span *ngIf="!canEdit">· read-only view</span> <span *ngIf="!canEdit">· read-only view</span>
<span class="er__gl-warn" *ngIf="unmappedCount() > 0"
>· {{ unmappedCount() }} without GL — map every product's GL before submitting</span
>
</div> </div>
<div class="er__grid-actions" *ngIf="canEdit"> <div class="er__grid-actions" *ngIf="canEdit">
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()"> <button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
@@ -65,6 +68,43 @@
</button> </button>
</div> </div>
</div> </div>
<div class="er__add-panel" *ngIf="addingProduct && canEdit">
<div class="er__add-title">Add product</div>
<div class="er__add-fields">
<div class="er__field">
<label>PRODUCT NAME</label>
<input type="text" [(ngModel)]="newProductName" placeholder="e.g. Education Loan" />
</div>
<div class="er__field er__field--gl">
<label>GL ACCOUNT (required)</label>
<mifosx-gl-picker [options]="glOptions" [(value)]="newProductGl"></mifosx-gl-picker>
</div>
<button mat-flat-button color="primary" (click)="confirmAddProduct()">Add</button>
<button mat-stroked-button (click)="cancelAddProduct()">Cancel</button>
</div>
<div class="er__add-error" *ngIf="addProductError">{{ addProductError }}</div>
</div>
<div class="er__add-panel" *ngIf="addingSlab && canEdit">
<div class="er__add-title">Add amount slab</div>
<div class="er__add-fields">
<div class="er__field">
<label>UPPER LIMIT (₹) — leave blank for "above the last slab"</label>
<input type="number" min="1" [(ngModel)]="newSlabAmount" placeholder="e.g. 5000000" />
</div>
<div class="er__field">
<label>SLAB LABEL</label>
<input
type="text"
[(ngModel)]="newSlabLabel"
[placeholder]="suggestedSlabLabel() || 'e.g. Upto 50,00,000 or > 50,00,000'"
/>
</div>
<button mat-flat-button color="primary" (click)="confirmAddSlab()">Add</button>
<button mat-stroked-button (click)="cancelAddSlab()">Cancel</button>
</div>
<div class="er__add-hint">The new slab is added as the last column; fill in its rates for each product.</div>
<div class="er__add-error" *ngIf="addSlabError">{{ addSlabError }}</div>
</div>
<div class="er__grid-scroll"> <div class="er__grid-scroll">
<table class="er__grid"> <table class="er__grid">
<thead> <thead>
@@ -82,6 +122,25 @@
<td class="er__col-particulars"> <td class="er__col-particulars">
<span class="er__srno">{{ row.srNo }}</span <span class="er__srno">{{ row.srNo }}</span
>{{ row.particulars }} >{{ row.particulars }}
<div class="er__gl" *ngIf="glEditRow !== i">
<span
class="er__gl-tag"
[class.er__gl-tag--unmapped]="row.glAccountId == null"
[class.er__gl-tag--editable]="canEdit"
(click)="editGl(i)"
>{{ row.glAccountId == null ? 'GL not mapped' : glLabel(row) }}</span
>
</div>
<mifosx-gl-picker
*ngIf="glEditRow === i"
class="er__gl-select"
[options]="glOptions"
[value]="row.glAccountId ?? null"
[allowClear]="true"
[openOnInit]="true"
(valueChange)="row.glAccountId = $event"
(closed)="glEditRow = null"
></mifosx-gl-picker>
</td> </td>
<td <td
*ngFor="let cell of row.rates; let j = index" *ngFor="let cell of row.rates; let j = index"
@@ -125,8 +184,14 @@
<button mat-stroked-button (click)="backToList()">{{ canEdit ? 'Cancel' : 'Back to list' }}</button> <button mat-stroked-button (click)="backToList()">{{ canEdit ? 'Cancel' : 'Back to list' }}</button>
<div class="er__footer-note">Rates in % p.a. · N.A. = slab not offered · formulas resolved at sanction</div> <div class="er__footer-note">Rates in % p.a. · N.A. = slab not offered · formulas resolved at sanction</div>
<div class="er__footer-actions" *ngIf="canEdit"> <div class="er__footer-actions" *ngIf="canEdit">
<button mat-stroked-button>Save draft</button> <button mat-stroked-button (click)="saveDraft()">Save draft</button>
<button mat-flat-button class="er__submit" (click)="submitForApproval()"> <button
mat-flat-button
class="er__submit"
(click)="submitForApproval()"
[disabled]="!canSubmit"
[title]="canSubmit ? '' : 'Map a GL account for every product first'"
>
<i class="fas fa-check"></i> Submit for approval <i class="fas fa-check"></i> Submit for approval
</button> </button>
</div> </div>
@@ -70,6 +70,85 @@
} }
} }
&__gl {
margin: 3px 0 0 30px;
}
&__gl-tag {
display: inline-block;
font-size: 10px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border-radius: 999px;
padding: 1px 8px;
white-space: nowrap;
&--unmapped {
color: #92400e;
background: #fef3c7;
}
&--editable {
cursor: pointer;
&:hover {
outline: 1px solid #94a3b8;
}
}
}
&__gl-select {
display: block;
margin: 4px 0 0 30px;
max-width: 340px;
}
&__gl-warn {
color: #b45309;
font-weight: 600;
}
&__add-panel {
padding: 14px 18px;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
}
&__add-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
}
&__add-fields {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
.er__field {
min-width: 240px;
}
.er__field--gl {
min-width: 320px;
}
}
&__add-hint {
margin-top: 8px;
font-size: 12px;
color: #64748b;
}
&__add-error {
margin-top: 8px;
font-size: 12px;
color: #b91c1c;
}
&__readonly-banner { &__readonly-banner {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6,7 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -18,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core'; import { MatNativeDateModule } from '@angular/material/core';
import { RateRegisterService } from '../rate-register.service'; import { RateRegisterService } from '../rate-register.service';
import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; import { GlOption, RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
import { GlPickerComponent } from '../gl-picker/gl-picker.component';
/** /**
* Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10 * Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10
@@ -39,10 +41,13 @@ import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate
MatSelectModule, MatSelectModule,
MatIconModule, MatIconModule,
MatDatepickerModule, MatDatepickerModule,
MatNativeDateModule MatNativeDateModule,
GlPickerComponent
] ]
}) })
export class EditRegisterComponent implements OnInit { export class EditRegisterComponent implements OnInit, OnDestroy {
private readonly snack = inject(MatSnackBar);
private readonly service = inject(RateRegisterService); private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
@@ -58,11 +63,42 @@ export class EditRegisterComponent implements OnInit {
@ViewChild('cellInput') cellInput?: ElementRef<HTMLInputElement>; @ViewChild('cellInput') cellInput?: ElementRef<HTMLInputElement>;
/** Leaving the page keeps the draft: edits are saved so a reload or a trip to Compare does not lose them. */
ngOnDestroy(): void {
if (this.canEdit) {
this.service.saveDrafts();
}
}
saveDraft(): void {
this.service.saveDrafts();
this.snack.open('Draft saved', 'OK', { duration: 2000 });
}
/** Posting GL accounts for the particular → GL mapping. */
glOptions: GlOption[] = [];
/** "Add product" panel state. A new product needs a name and a GL before it can be added. */
addingProduct = false;
newProductName = '';
newProductGl: number | null = null;
addProductError = '';
/** Index of the row whose GL is being changed inline, or null. */
glEditRow: number | null = null;
ngOnInit(): void { ngOnInit(): void {
this.service.getGlOptions().subscribe((options) => (this.glOptions = options));
const idParam = this.route.snapshot.queryParamMap.get('id'); const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null; const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => { this.service.list().subscribe((all) => {
this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
if (!found) {
// e.g. a reload on an unsaved local draft: nothing to edit, go back to the list.
this.router.navigate(['/products/loan-interest-rate-register']);
return;
}
this.register = found;
this.active = this.service.getActive(); this.active = this.service.getActive();
this.slabs = this.register.slabs; this.slabs = this.register.slabs;
this.rows = this.register.rows; this.rows = this.register.rows;
@@ -173,41 +209,130 @@ export class EditRegisterComponent implements OnInit {
this.editing = null; this.editing = null;
} }
/** Append a new empty product row at the bottom of the grid. */ glLabel(row: RateChartRow): string | null {
return this.service.glLabel(row.glAccountId);
}
unmappedCount(): number {
return this.rows.filter((r) => r.glAccountId == null).length;
}
/** Submit is blocked until every product has a GL. */
get canSubmit(): boolean {
return this.unmappedCount() === 0;
}
/** Opens the Add product panel. */
addProduct(): void { addProduct(): void {
if (!this.canEdit) { if (!this.canEdit) {
return; return;
} }
const name = window.prompt('New product name', 'New product'); this.addingSlab = false;
this.addingProduct = true;
this.newProductName = '';
this.newProductGl = null;
this.addProductError = '';
}
cancelAddProduct(): void {
this.addingProduct = false;
this.addProductError = '';
}
/** Append the new product row at the bottom of the grid. Name and GL are both mandatory. */
confirmAddProduct(): void {
const name = this.newProductName.trim();
if (!name) { if (!name) {
this.addProductError = 'Enter the product name.';
return;
}
if (this.rows.some((r) => r.particulars.trim().toLowerCase() === name.toLowerCase())) {
this.addProductError = 'A product with this name already exists.';
return;
}
if (this.newProductGl == null) {
this.addProductError = 'Select the GL account for this product.';
return; return;
} }
const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1; const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1;
this.rows.push({ this.rows.push({
srNo: next, srNo: next,
particulars: name.trim(), particulars: name,
glAccountId: this.newProductGl,
rates: Array(this.slabs.length).fill(null), rates: Array(this.slabs.length).fill(null),
isFormula: false isFormula: false
}); });
this.cancelAddProduct();
} }
/** Append a new amount slab column; every existing row grows by one blank cell. */ /** Toggle the inline GL selector on an existing row (Draft only). */
editGl(rowIdx: number): void {
if (this.canEdit) {
this.glEditRow = this.glEditRow === rowIdx ? null : rowIdx;
}
}
/** "Add slab" panel state. A slab is an amount band; at least a label or an upper limit is needed. */
addingSlab = false;
newSlabLabel = '';
newSlabAmount: number | null = null;
addSlabError = '';
/** Opens the Add slab panel. */
addSlab(): void { addSlab(): void {
if (!this.canEdit) { if (!this.canEdit) {
return; return;
} }
const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab'); this.addingProduct = false;
this.addingSlab = true;
this.newSlabLabel = '';
this.newSlabAmount = null;
this.addSlabError = '';
}
cancelAddSlab(): void {
this.addingSlab = false;
this.addSlabError = '';
}
/** Label that is filled in for a slab when only the upper limit is given, e.g. "Upto 50,00,000". */
suggestedSlabLabel(): string {
const amount = this.newSlabAmount;
return amount != null && Number.isFinite(amount) && amount > 0 ? `Upto ${amount.toLocaleString('en-IN')}` : '';
}
/** Append the new amount slab as the last column; every existing row grows by one blank cell. */
confirmAddSlab(): void {
const amount = this.newSlabAmount;
const hasAmount = amount != null && String(amount) !== '';
if (hasAmount && (!Number.isFinite(amount) || (amount as number) <= 0)) {
this.addSlabError = 'The upper limit must be a number greater than 0.';
return;
}
const label = this.newSlabLabel.trim() || this.suggestedSlabLabel();
if (!label) { if (!label) {
this.addSlabError = 'Enter a label, or an upper limit to build one from.';
return;
}
if (this.slabs.some((s) => s.label.trim().toLowerCase() === label.toLowerCase())) {
this.addSlabError = 'A slab with this label already exists.';
return;
}
if (hasAmount && this.slabs.some((s) => s.amountUpto === amount)) {
this.addSlabError = 'A slab with this upper limit already exists.';
return;
}
if (!hasAmount && this.slabs.some((s) => s.amountUpto === null)) {
this.addSlabError = 'An open-ended slab (no upper limit) already exists.';
return; return;
} }
const amountText = window.prompt('Amount upper bound in ₹ (leave blank for no cap)', '');
const amountUpto = amountText && amountText.trim() ? Number(amountText) : null;
this.slabs.push({ this.slabs.push({
key: `s_${Date.now()}`, key: `s_${Date.now()}`,
label: label.trim(), label,
amountUpto: amountUpto !== null && !Number.isFinite(amountUpto) ? null : amountUpto amountUpto: hasAmount ? (amount as number) : null
}); });
this.rows.forEach((r) => r.rates.push(null)); this.rows.forEach((r) => r.rates.push(null));
this.cancelAddSlab();
} }
editCibil(): void { editCibil(): void {
@@ -0,0 +1,135 @@
/**
* 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 {
AfterViewInit,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelect, MatSelectModule } from '@angular/material/select';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { Subject, takeUntil } from 'rxjs';
import { GlOption } from '../models/rate-register.model';
/**
* Compact searchable GL account dropdown. Typing in the search box filters by GL code or name.
* Emits the chosen GL id; with allowClear a "GL not mapped" entry emits null.
*/
@Component({
selector: 'mifosx-gl-picker',
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
NgxMatSelectSearchModule
],
template: `
<mat-form-field class="gl-picker" subscriptSizing="dynamic">
<mat-select
[value]="value"
[placeholder]="placeholder"
(selectionChange)="valueChange.emit($event.value)"
(openedChange)="onOpenedChange($event)"
>
<mat-option>
<ngx-mat-select-search
[formControl]="filterCtrl"
placeholderLabel="Search GL code or name"
noEntriesFoundLabel="No GL account found"
></ngx-mat-select-search>
</mat-option>
<mat-option *ngIf="allowClear" [value]="null">GL not mapped</mat-option>
<mat-option *ngFor="let o of filtered" [value]="o.id">{{ o.label }}</mat-option>
</mat-select>
</mat-form-field>
`,
styles: [
`
.gl-picker {
width: 100%;
min-width: 260px;
font-size: 12px;
}
`
]
})
export class GlPickerComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {
@Input() options: GlOption[] = [];
@Input() value: number | null = null;
@Input() placeholder = 'Select GL account';
@Input() allowClear = false;
/** Open the dropdown as soon as it is rendered (used for inline row editing). */
@Input() openOnInit = false;
@Output() valueChange = new EventEmitter<number | null>();
/** Fired when the dropdown panel closes, whether or not a value was picked. */
@Output() closed = new EventEmitter<void>();
@ViewChild(MatSelect) private select?: MatSelect;
filterCtrl = new UntypedFormControl('');
filtered: GlOption[] = [];
private readonly destroy$ = new Subject<void>();
ngOnInit(): void {
this.filterCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => this.applyFilter());
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['options'] || changes['value']) {
this.applyFilter();
}
}
ngAfterViewInit(): void {
if (this.openOnInit) {
setTimeout(() => this.select?.open());
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onOpenedChange(opened: boolean): void {
if (opened) {
this.filterCtrl.setValue('');
} else {
this.closed.emit();
}
}
/** Filter by code or name; the currently selected option always stays in the list so its label shows. */
private applyFilter(): void {
const q = ((this.filterCtrl.value as string) ?? '').trim().toLowerCase();
const all = this.options ?? [];
if (!q) {
this.filtered = all.slice();
return;
}
const matches = all.filter((o) => o.label.toLowerCase().includes(q));
const selected = all.find((o) => o.id === this.value);
this.filtered = selected && !matches.includes(selected) ? [
selected,
...matches
] : matches;
}
}
@@ -54,9 +54,8 @@
<div class="lirr__empty-icon"><i class="fas fa-folder-open"></i></div> <div class="lirr__empty-icon"><i class="fas fa-folder-open"></i></div>
<div class="lirr__empty-title">No rate register versions yet</div> <div class="lirr__empty-title">No rate register versions yet</div>
<div class="lirr__empty-sub"> <div class="lirr__empty-sub">
Create the first version to publish rates for all loan products. The template comes pre-filled from the bank's Create the first version to publish rates for all loan products. It starts with the standard loan types (rates
approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval.
submitting for approval.
</div> </div>
<button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()"> <button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
<i class="fas fa-plus"></i> Create first version <i class="fas fa-plus"></i> Create first version
@@ -72,7 +71,7 @@
> >
<mat-expansion-panel-header class="lirr__panel-header"> <mat-expansion-panel-header class="lirr__panel-header">
<mat-panel-title class="lirr__panel-title"> <mat-panel-title class="lirr__panel-title">
<span class="lirr__panel-name">{{ r.name }}</span> <span class="lirr__panel-name">{{ r.name || '(Untitled draft)' }}</span>
<span [class]="statusChipClass(r.status)">{{ statusLabel(r.status) }}</span> <span [class]="statusChipClass(r.status)">{{ statusLabel(r.status) }}</span>
</mat-panel-title> </mat-panel-title>
<mat-panel-description class="lirr__panel-desc"> <mat-panel-description class="lirr__panel-desc">
@@ -141,6 +140,9 @@
<td class="lirr__panel-grid-first"> <td class="lirr__panel-grid-first">
<span class="lirr__panel-grid-srno">{{ row.srNo }}</span <span class="lirr__panel-grid-srno">{{ row.srNo }}</span
>{{ row.particulars }} >{{ row.particulars }}
<span class="lirr__gl-tag" [class.lirr__gl-tag--unmapped]="row.glAccountId == null">{{
row.glAccountId == null ? 'GL not mapped' : glLabel(row.glAccountId)
}}</span>
</td> </td>
<td *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td> <td *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td>
</tr> </tr>
@@ -424,6 +424,22 @@
} }
} }
&__gl-tag {
display: inline-block;
margin-left: 8px;
font-size: 10px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border-radius: 999px;
padding: 1px 8px;
&--unmapped {
color: #92400e;
background: #fef3c7;
}
}
&__history-foot { &__history-foot {
padding: 10px 20px; padding: 10px 20px;
border-top: 1px solid #f1f5f9; border-top: 1px solid #f1f5f9;
@@ -38,9 +38,15 @@ export class LoanInterestRateRegisterComponent implements OnInit {
active?: RateRegister; active?: RateRegister;
ngOnInit(): void { ngOnInit(): void {
// Load the GL list once so mapped particulars show a readable label instead of "GL #id".
this.service.getGlOptions().subscribe();
this.reload(); this.reload();
} }
glLabel(id: number | null | undefined): string | null {
return this.service.glLabel(id);
}
private reload(): void { private reload(): void {
this.service.list().subscribe((rows) => { this.service.list().subscribe((rows) => {
this.registers = rows; this.registers = rows;
@@ -168,10 +174,11 @@ export class LoanInterestRateRegisterComponent implements OnInit {
/** Scaffold the very first version — used only when the register has no versions at all. */ /** Scaffold the very first version — used only when the register has no versions at all. */
createFirstVersion(): void { createFirstVersion(): void {
const draft = this.service.createFirstVersion(); this.service.createFirstVersion().subscribe((draft) => {
this.snack.open('Blank draft created — fill in rates and submit for approval', 'OK', { duration: 3000 }); this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
this.reload(); this.reload();
this.edit(draft); this.edit(draft);
});
} }
/** Open the Compare screen for a pending register (checker view). */ /** Open the Compare screen for a pending register (checker view). */
@@ -21,9 +21,17 @@ export interface RateChartRow {
srNo: number; srNo: number;
particulars: string; particulars: string;
isFormula?: boolean; isFormula?: boolean;
/** GL account this particular is mapped to. null/undefined = "GL not mapped". */
glAccountId?: number | null;
rates: RateCell[]; rates: RateCell[];
} }
/** One selectable GL account for the particular-to-GL mapping. */
export interface GlOption {
id: number;
label: string;
}
export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent'; export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent';
export interface CibilSlab { export interface CibilSlab {
@@ -48,121 +48,23 @@
</div> </div>
</div> </div>
<!-- Consolidated inline-diff rate chart --> <mifosx-rate-register-diff
<div class="pr__card"> [slabs]="slabs"
<div class="pr__card-head"> [draftRows]="draftRows"
<div> [baselineRows]="baselineRows"
<div class="pr__card-title">Rate chart — inline diff</div> [draftCibil]="draftCibil"
<div class="pr__card-sub">Changed cells highlighted · unchanged cells show single value</div> [baselineCibil]="baselineCibil"
</div> ></mifosx-rate-register-diff>
<mat-slide-toggle *ngIf="changedCellCount() > 0" [(ngModel)]="showOnlyChanged" class="pr__toggle">
Show only changed rows
</mat-slide-toggle>
</div>
<div class="pr__empty" *ngIf="changedCellCount() === 0 && changedCibilCount() === 0">
<i class="fas fa-equals"></i>
<div class="pr__empty-title">No changes detected</div>
<div class="pr__empty-sub">
Draft is identical to the current Active register. Edit the Draft first to see diffs here.
</div>
</div>
<div class="pr__scroll" *ngIf="changedCellCount() > 0">
<table class="pr__grid">
<thead>
<tr>
<th class="pr__col-particulars">Particulars</th>
<th *ngFor="let s of slabs" class="pr__col-rate">{{ s.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of visibleRows" [class.pr__row--changed]="rowHasChanges(row.srNo)">
<td class="pr__col-particulars">
<span class="pr__srno">{{ row.srNo }}</span
>{{ row.particulars }}
</td>
<td
*ngFor="let _ of slabs; let j = index"
class="pr__col-rate"
[class.pr__cell-changed]="cellChanged(row.srNo, j)"
>
<ng-container *ngIf="cellChanged(row.srNo, j); else sameCell">
<span class="pr__old">{{ display(baselineCell(row.srNo, j)) }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ display(draftCell(row.srNo, j)) }}</span>
</ng-container>
<ng-template #sameCell>{{ display(draftCell(row.srNo, j)) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CIBIL slabs inline-diff -->
<div class="pr__card" *ngIf="changedCibilCount() > 0">
<div class="pr__card-head">
<div>
<div class="pr__card-title">CIBIL slabs — inline diff</div>
<div class="pr__card-sub">Changed fields shown as OLD → NEW · unchanged fields render once</div>
</div>
</div>
<table class="pr__cibil">
<thead>
<tr>
<th>#</th>
<th>Score from</th>
<th>Score to</th>
<th>Tier</th>
<th>Adjustment</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of draftCibil; let i = index" [class.pr__row--changed]="cibilChanged(i)">
<td>{{ i + 1 }}</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'from')">
<ng-container *ngIf="cibilFieldChanged(i, 'from'); else sameFrom">
<span class="pr__old">{{ baselineCibil[i]?.from ?? '—' }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ slab.from }}</span>
</ng-container>
<ng-template #sameFrom>{{ slab.from }}</ng-template>
</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'to')">
<ng-container *ngIf="cibilFieldChanged(i, 'to'); else sameTo">
<span class="pr__old">{{ baselineCibil[i]?.to ?? '—' }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ slab.to }}</span>
</ng-container>
<ng-template #sameTo>{{ slab.to }}</ng-template>
</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'tier')">
<ng-container *ngIf="cibilFieldChanged(i, 'tier'); else sameTier">
<span class="pr__old">{{ baselineCibil[i]?.tier ?? '—' }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ slab.tier }}</span>
</ng-container>
<ng-template #sameTier>{{ slab.tier }}</ng-template>
</td>
<td [class.pr__cell-changed]="cibilFieldChanged(i, 'adjustmentPct')">
<ng-container *ngIf="cibilFieldChanged(i, 'adjustmentPct'); else sameAdj">
<span class="pr__old">{{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }}</span>
<span class="pr__arrow">→</span>
<span class="pr__new">{{ formatPct(slab.adjustmentPct) }}</span>
</ng-container>
<ng-template #sameAdj>{{ formatPct(slab.adjustmentPct) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pr__footer"> <div class="pr__footer">
<button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button> <button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button>
<div class="pr__footer-note" *ngIf="canSubmit"> <div class="pr__footer-note" *ngIf="canSubmit">
Submission will create a maker-checker request · the register becomes effective only after a checker approves it. Submission will create a maker-checker request · the register becomes effective only after a checker approves it.
</div> </div>
<div class="pr__footer-note" *ngIf="canSubmit && unmappedCount() > 0">
{{ unmappedCount() }} product(s) have no GL account. Go back to edit and map a GL for every product before
submitting.
</div>
<div class="pr__footer-note" *ngIf="!canSubmit"> <div class="pr__footer-note" *ngIf="!canSubmit">
Review-only — this version has already been submitted ({{ draft.status }}). Review-only — this version has already been submitted ({{ draft.status }}).
</div> </div>
@@ -172,7 +74,7 @@
color="primary" color="primary"
class="pr__submit" class="pr__submit"
(click)="submit()" (click)="submit()"
[disabled]="submitting || changedCellCount() + changedCibilCount() === 0" [disabled]="submitting || unmappedCount() > 0 || changedCellCount() + changedCibilCount() === 0"
> >
<i class="fas fa-paper-plane"></i> <i class="fas fa-paper-plane"></i>
{{ submitting ? 'Submitting…' : 'Submit for approval' }} {{ submitting ? 'Submitting…' : 'Submit for approval' }}
@@ -159,183 +159,6 @@
font-weight: 700; font-weight: 700;
} }
// ---- Card wrapper -----------------------------------------------------
&__card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
}
&__card-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__card-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__toggle {
font-size: 12px;
font-weight: 500;
}
// ---- Empty state (no changes) -----------------------------------------
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 20px;
color: #64748b;
i {
font-size: 28px;
color: #94a3b8;
}
}
&__empty-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__empty-sub {
font-size: 12px;
text-align: center;
max-width: 420px;
}
// ---- Scrollable rate chart --------------------------------------------
&__scroll {
overflow: auto;
max-height: 520px;
}
&__grid {
width: 100%;
border-collapse: collapse;
font-size: 12px;
thead th {
position: sticky;
top: 0;
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.3px;
padding: 10px;
text-align: right;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
thead th.pr__col-particulars {
text-align: left;
}
tbody td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
text-align: right;
font-variant-numeric: tabular-nums;
color: #0f172a;
white-space: nowrap;
}
tbody td.pr__col-particulars {
text-align: left;
font-weight: 500;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__srno {
display: inline-block;
min-width: 24px;
color: #94a3b8;
font-weight: 600;
margin-right: 6px;
}
// ---- Row & cell highlights --------------------------------------------
&__row--changed {
background: #fffbeb;
}
&__cell-changed {
background: #fef3c7 !important;
font-weight: 700;
}
&__old {
color: #991b1b;
font-weight: 500;
text-decoration: line-through;
text-decoration-color: #dc2626;
text-decoration-thickness: 1px;
}
&__arrow {
color: #94a3b8;
margin: 0 4px;
font-weight: 700;
}
&__new {
color: #065f46;
font-weight: 700;
}
// ---- CIBIL inline-diff table ------------------------------------------
&__cibil {
width: 100%;
border-collapse: collapse;
font-size: 12px;
thead th {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.3px;
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
color: #0f172a;
}
}
// ---- Footer ----------------------------------------------------------- // ---- Footer -----------------------------------------------------------
&__footer { &__footer {
@@ -8,13 +8,19 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import {
SubmitDetailsData,
SubmitDetailsDialogComponent,
SubmitDetailsResult
} from '../submit-details-dialog/submit-details-dialog.component';
import { RateRegisterService } from '../rate-register.service'; import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateCell, RateRegister, SlabColumn } from '../models/rate-register.model'; import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
import { RateRegisterDiffComponent } from '../rate-register-diff/rate-register-diff.component';
import { countChangedCells, countChangedCibil } from '../rate-register-diff.util';
/** /**
* Inline-diff Compare screen for a Loan Interest Rate Register draft. * Inline-diff Compare screen for a Loan Interest Rate Register draft.
@@ -32,9 +38,8 @@ import { CibilSlab, RateCell, RateRegister, SlabColumn } from '../models/rate-re
styleUrls: ['./preview-register.component.scss'], styleUrls: ['./preview-register.component.scss'],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
MatButtonModule, MatButtonModule,
MatSlideToggleModule RateRegisterDiffComponent
] ]
}) })
export class PreviewRegisterComponent implements OnInit { export class PreviewRegisterComponent implements OnInit {
@@ -42,30 +47,39 @@ export class PreviewRegisterComponent implements OnInit {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar); private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
draft!: RateRegister; draft!: RateRegister;
active?: RateRegister; active?: RateRegister;
slabs: SlabColumn[] = []; slabs: SlabColumn[] = [];
draftRows: { srNo: number; particulars: string; rates: RateCell[] }[] = []; draftRows: RateChartRow[] = [];
baselineRows: { srNo: number; particulars: string; rates: RateCell[] }[] = []; baselineRows: RateChartRow[] = [];
draftCibil: CibilSlab[] = []; draftCibil: CibilSlab[] = [];
baselineCibil: CibilSlab[] = []; baselineCibil: CibilSlab[] = [];
submitting = false; submitting = false;
/** User toggle to collapse the chart to only the rows that changed. */
showOnlyChanged = true;
/** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */ /** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */
get canSubmit(): boolean { get canSubmit(): boolean {
return this.draft?.status === 'Draft'; return this.draft?.status === 'Draft';
} }
/** Products still without a GL; the backend rejects the save while any remain. */
unmappedCount(): number {
return this.draftRows.filter((r) => r.glAccountId == null).length;
}
ngOnInit(): void { ngOnInit(): void {
const idParam = this.route.snapshot.queryParamMap.get('id'); const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null; const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => { this.service.list().subscribe((all) => {
this.draft = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
if (!found) {
this.router.navigate(['/products/loan-interest-rate-register']);
return;
}
this.draft = found;
this.active = this.service.getActive(); this.active = this.service.getActive();
this.slabs = this.draft.slabs; this.slabs = this.draft.slabs;
this.draftRows = this.draft.rows; this.draftRows = this.draft.rows;
@@ -78,97 +92,12 @@ export class PreviewRegisterComponent implements OnInit {
}); });
} }
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
/** The baseline (OLD) value of a cell — null if the row did not exist in the baseline. */
baselineCell(srNo: number, colIdx: number): RateCell {
return this.baselineRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
/** The draft (NEW) value of a cell. */
draftCell(srNo: number, colIdx: number): RateCell {
return this.draftRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
/** True when the same {product, amount-slab} cell differs between baseline and draft. */
cellChanged(srNo: number, colIdx: number): boolean {
return !this.sameCell(this.baselineCell(srNo, colIdx), this.draftCell(srNo, colIdx));
}
/** True when a product row has at least one changed cell — used by the "show only changed" filter. */
rowHasChanges(srNo: number): boolean {
for (let j = 0; j < this.slabs.length; j++) {
if (this.cellChanged(srNo, j)) {
return true;
}
}
return false;
}
/** Rows the chart should actually render — all rows, or only the ones with any diff. */
get visibleRows(): { srNo: number; particulars: string; rates: RateCell[] }[] {
if (!this.showOnlyChanged || this.changedCellCount() === 0) {
return this.draftRows;
}
return this.draftRows.filter((r) => this.rowHasChanges(r.srNo));
}
cibilChanged(index: number): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
if (!a || !d) {
return true;
}
return a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct;
}
/** Which CIBIL field(s) differ between baseline and draft — drives per-field highlight. */
cibilFieldChanged(index: number, field: 'from' | 'to' | 'tier' | 'adjustmentPct'): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
if (!a || !d) {
return true;
}
return a[field] !== d[field];
}
changedCellCount(): number { changedCellCount(): number {
let n = 0; return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
for (const row of this.draftRows) {
for (let j = 0; j < this.slabs.length; j++) {
if (this.cellChanged(row.srNo, j)) {
n++;
}
}
}
return n;
} }
changedCibilCount(): number { changedCibilCount(): number {
let n = 0; return countChangedCibil(this.draftCibil, this.baselineCibil);
const max = Math.max(this.draftCibil.length, this.baselineCibil.length);
for (let i = 0; i < max; i++) {
if (this.cibilChanged(i)) {
n++;
}
}
return n;
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
} }
/** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */ /** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */
@@ -176,6 +105,33 @@ export class PreviewRegisterComponent implements OnInit {
if (this.submitting) { if (this.submitting) {
return; return;
} }
const ref = this.dialog.open<SubmitDetailsDialogComponent, SubmitDetailsData, SubmitDetailsResult>(
SubmitDetailsDialogComponent,
{
width: '520px',
maxWidth: '95vw',
data: {
name: this.draft.name,
effectiveFrom: this.draft.effectiveFrom,
effectiveTo: this.draft.effectiveTo ?? '',
activeName: this.active && this.active.id !== this.draft.id ? this.active.name : undefined,
activeFrom: this.active && this.active.id !== this.draft.id ? this.active.effectiveFrom : undefined
}
}
);
ref.afterClosed().subscribe((details) => {
if (!details) {
return;
}
this.draft.name = details.name;
this.draft.effectiveFrom = details.effectiveFrom;
this.draft.effectiveTo = details.effectiveTo || null;
this.service.saveDrafts();
this.send();
});
}
private send(): void {
this.submitting = true; this.submitting = true;
this.service.submitForApproval(this.draft.id).subscribe({ this.service.submitForApproval(this.draft.id).subscribe({
next: (res) => { next: (res) => {
@@ -189,7 +145,12 @@ export class PreviewRegisterComponent implements OnInit {
// we still unlock the UI so the user can retry instead of staring at a spinner forever. // we still unlock the UI so the user can retry instead of staring at a spinner forever.
this.submitting = false; this.submitting = false;
console.error('[PreviewRegister] submit failed', err); console.error('[PreviewRegister] submit failed', err);
this.snack.open('Submit failed — check backend status and try again', 'Dismiss', { duration: 4500 }); const reason =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.message ||
'check backend status and try again';
this.snack.open(`Submit failed — ${reason}`, 'Dismiss', { duration: 8000 });
} }
}); });
} }
@@ -197,14 +158,4 @@ export class PreviewRegisterComponent implements OnInit {
backToEdit(): void { backToEdit(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } }); this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } });
} }
private sameCell(a: RateCell, b: RateCell): boolean {
if (a === null && b === null) {
return true;
}
if (typeof a === 'number' && typeof b === 'number') {
return Math.abs(a - b) < 1e-9;
}
return a === b;
}
} }
@@ -0,0 +1,48 @@
/**
* 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 { CibilSlab, RateCell, RateChartRow } from './models/rate-register.model';
/** Two chart cells are the same when both are N.A., both the same formula, or numerically equal. */
export function sameCell(a: RateCell, b: RateCell): boolean {
if (a === null && b === null) {
return true;
}
if (typeof a === 'number' && typeof b === 'number') {
return Math.abs(a - b) < 1e-9;
}
return a === b;
}
/** Number of {product, amount-slab} cells whose value differs between the draft and the baseline. */
export function countChangedCells(draft: RateChartRow[], baseline: RateChartRow[], slabCount: number): number {
let n = 0;
for (const row of draft) {
const base = baseline.find((r) => r.srNo === row.srNo);
for (let j = 0; j < slabCount; j++) {
if (!sameCell(base?.rates[j] ?? null, row.rates[j] ?? null)) {
n++;
}
}
}
return n;
}
/** Number of CIBIL bands that differ (by position) between the draft and the baseline. */
export function countChangedCibil(draft: CibilSlab[], baseline: CibilSlab[]): number {
let n = 0;
const max = Math.max(draft.length, baseline.length);
for (let i = 0; i < max; i++) {
const a = baseline[i];
const d = draft[i];
if (!a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct) {
n++;
}
}
return n;
}
@@ -0,0 +1,118 @@
<!--
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/.
-->
<!-- Consolidated inline-diff rate chart -->
<div class="rd__card">
<div class="rd__card-head">
<div>
<div class="rd__card-title">Rate chart — inline diff</div>
<div class="rd__card-sub">Changed cells highlighted · unchanged cells show single value</div>
</div>
<mat-slide-toggle *ngIf="changedCellCount() > 0" [(ngModel)]="showOnlyChanged" class="rd__toggle">
Show only changed rows
</mat-slide-toggle>
</div>
<div class="rd__empty" *ngIf="changedCellCount() === 0 && changedCibilCount() === 0">
<i class="fas fa-equals"></i>
<div class="rd__empty-title">No changes detected</div>
<div class="rd__empty-sub">The proposed version is identical to the current Active register.</div>
</div>
<div class="rd__scroll" *ngIf="changedCellCount() > 0">
<table class="rd__grid">
<thead>
<tr>
<th class="rd__col-particulars">Particulars</th>
<th *ngFor="let s of slabs" class="rd__col-rate">{{ s.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of visibleRows" [class.rd__row--changed]="rowHasChanges(row.srNo)">
<td class="rd__col-particulars">
<span class="rd__srno">{{ row.srNo }}</span
>{{ row.particulars }}
<div>
<span class="rd__gl-tag" [class.rd__gl-tag--unmapped]="row.glAccountId == null">{{ glText(row) }}</span>
</div>
</td>
<td
*ngFor="let _ of slabs; let j = index"
class="rd__col-rate"
[class.rd__cell-changed]="cellChanged(row.srNo, j)"
>
<ng-container *ngIf="cellChanged(row.srNo, j); else sameCell">
<span class="rd__old">{{ display(baselineCell(row.srNo, j)) }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ display(draftCell(row.srNo, j)) }}</span>
</ng-container>
<ng-template #sameCell>{{ display(draftCell(row.srNo, j)) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CIBIL slabs inline-diff -->
<div class="rd__card" *ngIf="changedCibilCount() > 0">
<div class="rd__card-head">
<div>
<div class="rd__card-title">CIBIL slabs — inline diff</div>
<div class="rd__card-sub">Changed fields shown as OLD → NEW · unchanged fields render once</div>
</div>
</div>
<table class="rd__cibil">
<thead>
<tr>
<th>#</th>
<th>Score from</th>
<th>Score to</th>
<th>Tier</th>
<th>Adjustment</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of draftCibil; let i = index" [class.rd__row--changed]="cibilChanged(i)">
<td>{{ i + 1 }}</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'from')">
<ng-container *ngIf="cibilFieldChanged(i, 'from'); else sameFrom">
<span class="rd__old">{{ baselineCibil[i]?.from ?? '—' }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ slab.from }}</span>
</ng-container>
<ng-template #sameFrom>{{ slab.from }}</ng-template>
</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'to')">
<ng-container *ngIf="cibilFieldChanged(i, 'to'); else sameTo">
<span class="rd__old">{{ baselineCibil[i]?.to ?? '—' }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ slab.to }}</span>
</ng-container>
<ng-template #sameTo>{{ slab.to }}</ng-template>
</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'tier')">
<ng-container *ngIf="cibilFieldChanged(i, 'tier'); else sameTier">
<span class="rd__old">{{ baselineCibil[i]?.tier ?? '—' }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ slab.tier }}</span>
</ng-container>
<ng-template #sameTier>{{ slab.tier }}</ng-template>
</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'adjustmentPct')">
<ng-container *ngIf="cibilFieldChanged(i, 'adjustmentPct'); else sameAdj">
<span class="rd__old">{{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ formatPct(slab.adjustmentPct) }}</span>
</ng-container>
<ng-template #sameAdj>{{ formatPct(slab.adjustmentPct) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
@@ -0,0 +1,206 @@
/**
* 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: flex;
flex-direction: column;
gap: 16px;
}
.rd {
&__card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
}
&__card-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__card-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__toggle {
font-size: 12px;
font-weight: 500;
}
// ---- Empty state (no changes) -----------------------------------------
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 20px;
color: #64748b;
i {
font-size: 28px;
color: #94a3b8;
}
}
&__empty-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__empty-sub {
font-size: 12px;
text-align: center;
max-width: 420px;
}
// ---- Scrollable rate chart --------------------------------------------
&__scroll {
overflow: auto;
max-height: 520px;
}
&__grid {
width: 100%;
border-collapse: collapse;
font-size: 12px;
thead th {
position: sticky;
top: 0;
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.3px;
padding: 10px;
text-align: right;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
thead th.rd__col-particulars {
text-align: left;
}
tbody td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
text-align: right;
font-variant-numeric: tabular-nums;
color: #0f172a;
white-space: nowrap;
}
tbody td.rd__col-particulars {
text-align: left;
font-weight: 500;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__srno {
display: inline-block;
min-width: 24px;
color: #94a3b8;
font-weight: 600;
margin-right: 6px;
}
&__gl-tag {
display: inline-block;
margin-left: 30px;
font-size: 10px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border-radius: 999px;
padding: 1px 8px;
&--unmapped {
color: #92400e;
background: #fef3c7;
}
}
// ---- Row & cell highlights --------------------------------------------
&__row--changed {
background: #fffbeb;
}
&__cell-changed {
background: #fef3c7 !important;
font-weight: 700;
}
&__old {
color: #991b1b;
font-weight: 500;
text-decoration: line-through;
text-decoration-color: #dc2626;
text-decoration-thickness: 1px;
}
&__arrow {
color: #94a3b8;
margin: 0 4px;
font-weight: 700;
}
&__new {
color: #065f46;
font-weight: 700;
}
// ---- CIBIL inline-diff table ------------------------------------------
&__cibil {
width: 100%;
border-collapse: collapse;
font-size: 12px;
thead th {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.3px;
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
color: #0f172a;
}
}
}
@@ -0,0 +1,121 @@
/**
* 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, Input, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateCell, RateChartRow, SlabColumn } from '../models/rate-register.model';
import { countChangedCells, countChangedCibil, sameCell } from '../rate-register-diff.util';
/**
* Inline-diff view of a proposed Loan Interest Rate Register version against a baseline: one rate
* chart where every changed cell reads OLD -> NEW, plus the CIBIL bands. Shared by the Compare
* screen (before submitting) and the Approvals dialog (before approving), so both show the same thing.
*/
@Component({
selector: 'mifosx-rate-register-diff',
templateUrl: './rate-register-diff.component.html',
styleUrls: ['./rate-register-diff.component.scss'],
imports: [
CommonModule,
FormsModule,
MatSlideToggleModule
]
})
export class RateRegisterDiffComponent implements OnInit {
private readonly service = inject(RateRegisterService);
@Input() slabs: SlabColumn[] = [];
@Input() draftRows: RateChartRow[] = [];
@Input() baselineRows: RateChartRow[] = [];
@Input() draftCibil: CibilSlab[] = [];
@Input() baselineCibil: CibilSlab[] = [];
/** User toggle to collapse the chart to only the rows that changed. */
showOnlyChanged = true;
ngOnInit(): void {
// Loads the GL list so mapped particulars show a readable label; the cache is shared.
this.service.getGlOptions().subscribe();
}
glText(row: RateChartRow): string {
return row.glAccountId == null ? 'GL not mapped' : (this.service.glLabel(row.glAccountId) ?? 'GL not mapped');
}
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
/** The baseline (OLD) value of a cell; null when the row did not exist in the baseline. */
baselineCell(srNo: number, colIdx: number): RateCell {
return this.baselineRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
draftCell(srNo: number, colIdx: number): RateCell {
return this.draftRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
cellChanged(srNo: number, colIdx: number): boolean {
return !sameCell(this.baselineCell(srNo, colIdx), this.draftCell(srNo, colIdx));
}
rowHasChanges(srNo: number): boolean {
for (let j = 0; j < this.slabs.length; j++) {
if (this.cellChanged(srNo, j)) {
return true;
}
}
return false;
}
/** Rows to render: all of them, or only those with a change when the toggle is on. */
get visibleRows(): RateChartRow[] {
if (!this.showOnlyChanged || this.changedCellCount() === 0) {
return this.draftRows;
}
return this.draftRows.filter((r) => this.rowHasChanges(r.srNo));
}
changedCellCount(): number {
return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
}
changedCibilCount(): number {
return countChangedCibil(this.draftCibil, this.baselineCibil);
}
cibilChanged(index: number): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
return !a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct;
}
/** Per-field highlight inside a CIBIL row. */
cibilFieldChanged(index: number, field: 'from' | 'to' | 'tier' | 'adjustmentPct'): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
return !a || !d || a[field] !== d[field];
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
}
}
@@ -7,10 +7,11 @@
*/ */
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Observable, catchError, map, of, tap } from 'rxjs'; import { Observable, catchError, forkJoin, map, of, switchMap, tap } from 'rxjs';
import { ProductsService } from '../products.service'; import { ProductsService } from '../products.service';
import { import {
CibilSlab, CibilSlab,
GlOption,
RateCell, RateCell,
RateChartRow, RateChartRow,
RateRegister, RateRegister,
@@ -21,10 +22,9 @@ import {
/** /**
* Central Loan Interest Rate Register service. * Central Loan Interest Rate Register service.
* *
* Primary data path is HTTP (/loanproducts/baseinterestrates). On HTTP failure (backend not yet * Data comes from HTTP (/loanproducts/baseinterestrates); there is no built-in sample data. If the
* deployed, network error) the service transparently falls back to in-memory seeded data so the UI * backend cannot be reached the list is empty apart from local Drafts (not-yet-submitted new
* never breaks during development. Local Drafts (not-yet-submitted new versions) live only in * versions), which live only in memory and are merged in front of server data on every list().
* memory and are merged in front of server data on every list().
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class RateRegisterService { export class RateRegisterService {
@@ -37,7 +37,100 @@ export class RateRegisterService {
private cache: RateRegister[] = []; private cache: RateRegister[] = [];
/** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */ /** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */
private localDrafts: RateRegister[] = []; private localDrafts: RateRegister[] = this.loadDrafts();
private static readonly DRAFTS_KEY = 'cbsLoanRateRegisterDrafts';
/** Drafts are kept in this browser's localStorage so a reload does not lose the maker's work. */
private loadDrafts(): RateRegister[] {
try {
const raw = localStorage.getItem(RateRegisterService.DRAFTS_KEY);
const parsed = raw ? (JSON.parse(raw) as RateRegister[]) : [];
return Array.isArray(parsed) ? parsed.filter((d) => d?.status === 'Draft') : [];
} catch {
return [];
}
}
/** Persist the current drafts (call after any edit the user should not lose). */
saveDrafts(): void {
try {
localStorage.setItem(
RateRegisterService.DRAFTS_KEY,
JSON.stringify(this.localDrafts.filter((d) => d.status === 'Draft'))
);
} catch {
/* storage unavailable (private mode / quota): the draft just stays in memory */
}
}
/** Base for the synthetic ids of pending server entries: -(base + commandId). Local drafts count down from -1. */
private static readonly PENDING_ID_BASE = 1_000_000;
/** One maker-checker entry (with its commandAsJson) as a read-only register, e.g. for the Approvals dialog. */
registerFromPendingEntry(entry: any): RateRegister | null {
return this.pendingToRegisters([entry])[0] ?? null;
}
/** A version as the backend returns it, as a UI register; used as the "old" side of a comparison. */
registerFromServerDto(dto: any): RateRegister {
return this.fromBackend(dto, new Map());
}
/** Turn the maker-checker entries awaiting a checker into read-only "Pending Approval" registers. */
private pendingToRegisters(entries: any): RateRegister[] {
const items: any[] = Array.isArray(entries) ? entries : (entries?.pageItems ?? []);
const out: RateRegister[] = [];
for (const e of items) {
if ((e?.entityName ?? '').toUpperCase() !== 'LOANBASEINTERESTRATE' || !e?.commandAsJson) {
continue;
}
let payload: any;
try {
payload = JSON.parse(e.commandAsJson);
} catch {
continue;
}
const glBySrNo = new Map<number, number | null>();
for (const p of payload.products ?? []) {
glBySrNo.set(p.srNo, p.glAccountId ?? null);
}
const madeOn =
typeof e.madeOnDate === 'number'
? new Date(e.madeOnDate < 1e12 ? e.madeOnDate * 1000 : e.madeOnDate).toISOString().slice(0, 10)
: String(e.madeOnDate ?? '').slice(0, 10);
const reg = this.fromBackend(
{
id: -(RateRegisterService.PENDING_ID_BASE + e.id),
name: payload.name,
effectiveFromDate: payload.effectiveFromDate ?? madeOn,
effectiveToDate: null,
currencyCode: payload.currencyCode,
isActive: false,
fallbackNoCibilSlab: payload.fallbackNoCibilSlab,
fallbackBelowMin: payload.fallbackBelowMin,
note: payload.note,
createdDate: madeOn,
slabs: payload.slabs,
cibilSlabs: payload.cibilSlabs
},
glBySrNo
);
reg.status = 'Pending Approval';
reg.createdBy = e.maker ?? 'Maker';
reg.lastModified = madeOn;
// With no Active register to diff against, the Compare screen falls back to this blank baseline.
reg.baselineRows = reg.rows.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
glAccountId: r.glAccountId ?? null,
rates: new Array(r.rates.length).fill(null)
}));
reg.baselineCibilSlabs = [];
out.push(reg);
}
return out;
}
private readonly slabs: SlabColumn[] = [ private readonly slabs: SlabColumn[] = [
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 }, { key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
@@ -52,304 +145,6 @@ export class RateRegisterService {
{ key: 's10', label: '> 25,00,000', amountUpto: null } { key: 's10', label: '> 25,00,000', amountUpto: null }
]; ];
// Values taken from the bank's approved Interest Rate of Loans register.
private readonly rows: RateChartRow[] = [
this.row(1, 'Consumer Loan', [
18,
18,
15,
13,
null,
null,
null,
null,
null,
null
]),
this.row(
2,
'Self Employment Loan',
[
18,
18,
15,
13,
12.5,
12,
11.5,
11,
10.5,
10.5
]
),
this.row(
3,
'Business Term Loan for running business',
[
18,
18,
15,
13,
12.5,
11,
10.5,
10,
9.5,
9.5
]
),
this.row(
4,
'Cash Credit',
[
18,
18,
15,
13,
13,
11,
10.5,
10,
9.5,
9.5
]
),
this.row(
5,
'Limit (OD) on property — MSME',
[
18,
18,
15,
13,
13,
11,
10.5,
10,
9.5,
9.5
]
),
this.row(
6,
'Limit (OD) on property — Personal',
[
18,
18,
15,
14,
13.5,
12,
11.5,
11,
10.5,
10.5
]
),
this.row(7, 'House Purchase / Construction', [
null,
null,
15,
12,
12,
9.25,
8.75,
8.25,
8,
8
]),
this.row(8, 'House Repairing, Renovation & Expansion Loan', [
null,
null,
15,
14,
14,
13,
12,
11,
10.5,
10
]),
this.row(
9,
'Commercial Real Estate Loan',
[
18,
18,
15,
13,
12.5,
12,
11,
10.5,
10.5,
10
]
),
this.row(10, 'Loan against Jewellery', this.flat(10.8)),
this.row(11, 'Loan against Warehouse Receipt (Farmers)', this.flat(13)),
this.row(
12,
'Loan against Warehouse Receipt (Traders)',
[
10.5,
10.5,
10.5,
10.5,
10.5,
10.5,
10,
10,
10,
9
]
),
this.row(13, 'Agriculture Loan', [
null,
null,
15,
12,
12,
12,
11.75,
11.5,
11.25,
11
]),
this.row(14, 'Personal Loan (Unsecured)', [
18,
18,
16,
15,
null,
null,
null,
null,
null,
null
]),
this.row(
15,
'Personal Loan (Secured / Property)',
[
18,
18,
16,
15,
15,
13,
12,
11.5,
11,
11
]
),
this.row(16, 'Two Wheeler Loan', this.flat(13)),
this.row(17, 'Car Loan (4-Wheel Domestic)', [
null,
null,
10,
9.5,
9.5,
8.75,
8.5,
8.35,
8.25,
8.25
]),
this.row(18, 'Old Car Loan (4-Wheel Domestic) & Refinance', [
null,
null,
13,
12.5,
12.5,
12,
11.5,
11,
10.5,
10.5
]),
this.row(19, 'Vehicle Loan (Commercial & Three-wheeler)', [
null,
null,
13,
12,
12,
10.5,
10,
10,
10,
10
]),
this.row(20, 'Old Vehicle Loan (Commercial & Three-wheeler)', [
null,
null,
15,
14,
14,
13.5,
13.5,
13,
12.5,
12.5
]),
this.row(
21,
'Machinery & Plant Loan',
[
18,
18,
15,
13,
12.5,
12,
11,
10.5,
10.5,
10
]
),
this.row(
22,
'Office Chamber / Equipment Loan',
[
18,
18,
15,
13,
12.5,
12,
11,
10.5,
10.5,
10
]
),
this.formulaRow(23, 'Loan against F.D.R.', [
'1% + FD',
'1% + FD',
'1% + FD',
'1% + FD',
'1% + FD',
'1% + FD',
'0.75% + FD',
'0.50% + FD',
'0.50% + FD',
'0.50% + FD'
]),
this.row(24, 'Loan against Daily Deposit & Gullak Scheme', this.flat(10)),
this.formulaRow(25, 'Loan against Recurring Deposit', Array(10).fill('1% + RD')),
this.row(26, 'Loan against Staff Service Fund', this.flat(7)),
this.row(27, 'Over Draft A/c', this.flat(null)),
this.row(28, 'Loan on LIC Policy', this.flat(null)),
this.row(29, 'Loan on KVP / NSC', this.flat(null)),
this.row(30, 'Bill Discount', this.flat(null)),
this.row(31, 'Temporary Limit', this.flat(15)),
this.row(32, 'Bank Guarantee (commission)', this.flat(0.25)),
this.row(33, 'Sugam Vyapar Loan', this.flat(null)),
this.row(34, 'OD to Staff (upto 2 lakh)', this.flat(null))
];
// Credit-score bands per the bank's approved register: // Credit-score bands per the bank's approved register:
// above 700 = base rate (no change), below that stepped up by 0.5% per band. // above 700 = base rate (no change), below that stepped up by 0.5% per band.
// No discount band — the register never gives a negative adjustment. // No discount band — the register never gives a negative adjustment.
@@ -361,86 +156,81 @@ export class RateRegisterService {
{ from: 701, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: 0 } { from: 701, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: 0 }
]; ];
// Seed data used ONLY when the HTTP backend is unreachable (dev fallback). Each entry has its own /**
// deep-cloned rows & CIBIL slabs so edits to one register do not mutate another. * The standard loan types every bank offers. Same list the backend seeds into
private readonly seedRegisters: RateRegister[] = [ * m_loan_rate_register_product; used only if that endpoint cannot be reached when the first
this.seedRegister({ * version is created. Any other particular is added by the bank itself.
id: 1001, */
name: 'Interest Rate of Loans — Effective 01 April 2027', private readonly defaultParticulars: { srNo: number; name: string }[] = [
effectiveFrom: '2027-04-01', { srNo: 1, name: 'Housing Loan (Purchase/Construction)' },
effectiveTo: '2028-03-31', { srNo: 2, name: 'Home Repair/Renovation' },
status: 'Draft', { srNo: 3, name: 'Personal Loan (Unsecured)' },
createdBy: 'Sonali', { srNo: 4, name: 'Personal Loan (Secured/Property)' },
lastModified: '2026-10-01', { srNo: 5, name: 'Two Wheeler Loan' },
note: 'Scheduled · future effective' { srNo: 6, name: 'Car Loan (New)' },
// No pre-seeded deltas — Draft starts identical to the Active register. { srNo: 7, name: 'Used Car/Vehicle Loan' },
// Only edits made in the UI will show as diffs on the Compare screen. { srNo: 8, name: 'Commercial Vehicle Loan' },
}), { srNo: 9, name: 'Gold Loan' },
this.seedRegister({ { srNo: 10, name: 'Business Term Loan' },
id: 1002, { srNo: 11, name: 'Machinery & Equipment Loan' },
name: 'Interest Rate of Loans — Effective 01 April 2026', { srNo: 12, name: 'Cash Credit' },
effectiveFrom: '2026-04-01', { srNo: 13, name: 'Overdraft' },
effectiveTo: '2027-03-31', { srNo: 14, name: 'Agriculture Loan' },
status: 'Active', { srNo: 15, name: 'Loan against FDR' },
createdBy: 'Admin', { srNo: 16, name: 'Loan against RD' }
lastModified: '2026-03-25',
note: 'Approved by Board · Nagrik Sahakari Bank Maryadit Vidisha'
}),
this.seedRegister({
id: 1003,
name: 'Interest Rate of Loans — Effective 01 April 2025',
effectiveFrom: '2025-04-01',
effectiveTo: '2026-03-31',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2025-03-20'
}),
this.seedRegister({
id: 1004,
name: 'Interest Rate of Loans — Effective 01 April 2024',
effectiveFrom: '2024-04-01',
effectiveTo: '2025-03-31',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2024-03-18'
}),
this.seedRegister({
id: 1005,
name: 'Interest Rate of Loans — Effective 01 April 2023',
effectiveFrom: '2023-04-01',
effectiveTo: '2024-03-31',
status: 'Expired',
createdBy: 'Admin',
lastModified: '2023-03-22'
})
]; ];
/** /**
* List all registers (local drafts + server versions). Tries HTTP first; on failure OR when * List all registers (local drafts + server versions). If the backend cannot be reached only the
* the server returns an empty collection (fresh DB, nothing seeded yet) falls back to the * local drafts are returned: there is no built-in sample data. A successful but empty response is a
* built-in seed registers so the UI always has something to show in development. * genuinely empty register and shows the "Create first version" state.
*/ */
list(): Observable<RateRegister[]> { list(): Observable<RateRegister[]> {
if (!this.useHttp) { if (!this.useHttp) {
this.cache = [ this.cache = [...this.localDrafts];
...this.localDrafts,
...this.seedRegisters
];
return of(this.cache); return of(this.cache);
} }
return this.productsService.getLoanBaseInterestRates().pipe( // Approved versions, the particulars master (each row's GL) and the versions still awaiting a
map((dtos: any[]) => (dtos ?? []).map((d) => this.fromBackend(d))), // checker. A failure of the master or the pending lookup must not hide the approved versions.
catchError((err) => { return forkJoin({
console.warn('[RateRegisterService] HTTP unavailable, using seeded data —', err?.message ?? err); versions: this.productsService.getLoanBaseInterestRates(),
this.useHttp = false; products: this.productsService.getLoanRateRegisterProducts().pipe(catchError(() => of([]))),
return of([...this.seedRegisters]); pending: this.productsService.getPendingLoanRateRegisters().pipe(
}), // The list omits each entry's payload, so fetch the detail of every pending entry.
map((serverVersions) => { switchMap((entries: any) => {
if (serverVersions.length === 0) { const items: any[] = (Array.isArray(entries) ? entries : (entries?.pageItems ?? [])).filter(
console.info('[RateRegisterService] Backend returned no versions — using seeded demo data.'); (e: any) => (e?.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE'
return [...this.seedRegisters]; );
if (items.length === 0) {
return of([] as any[]);
}
return forkJoin(
items.map((e) =>
this.productsService.getLoanRateRegisterAuditDetail(e.id).pipe(
map((detail: any) => ({ ...detail, ...e })),
catchError(() => of(null))
)
)
).pipe(map((all) => all.filter((x) => x !== null)));
}),
catchError(() => of([] as any[]))
)
}).pipe(
map(({ versions, products, pending }) => {
const glBySrNo = new Map<number, number | null>();
for (const p of (products as any[]) ?? []) {
glBySrNo.set(p.srNo, p.glAccountId ?? null);
} }
return serverVersions; const approved = ((versions as any[]) ?? []).map((d) => this.fromBackend(d, glBySrNo));
return [
...this.pendingToRegisters(pending),
...approved
];
}),
catchError((err) => {
console.warn('[RateRegisterService] Backend unavailable —', err?.message ?? err);
this.useHttp = false;
return of([] as RateRegister[]);
}), }),
tap((effectiveVersions) => { tap((effectiveVersions) => {
this.cache = [ this.cache = [
@@ -462,10 +252,10 @@ export class RateRegisterService {
return of(this.localDrafts.find((r) => r.id === id)); return of(this.localDrafts.find((r) => r.id === id));
} }
if (!this.useHttp) { if (!this.useHttp) {
return of(this.seedRegisters.find((r) => r.id === id)); return of(undefined);
} }
return this.productsService.getLoanBaseInterestRateById(id).pipe( return this.productsService.getLoanBaseInterestRateById(id).pipe(
map((d: any) => (d ? this.fromBackend(d) : undefined)), map((d: any) => (d ? this.fromBackend(d, new Map()) : undefined)),
catchError(() => of(undefined)) catchError(() => of(undefined))
); );
} }
@@ -476,12 +266,9 @@ export class RateRegisterService {
} }
/** The one shown by default on list → edit navigation. */ /** The one shown by default on list → edit navigation. */
getActiveOrFirstDraft(): RateRegister { getActiveOrFirstDraft(): RateRegister | undefined {
return ( return (
this.cache.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Active') ?? this.cache[0]
this.cache.find((r) => r.status === 'Active') ??
this.cache[0] ??
this.seedRegisters[0]
); );
} }
@@ -507,37 +294,20 @@ export class RateRegisterService {
return of({ rollbackTransaction: false }); return of({ rollbackTransaction: false });
} }
if (!this.useHttp) {
// Dev fallback — flip status locally so the UI can still demo the flow.
draft.status = 'Pending Approval';
draft.lastModified = new Date().toISOString().slice(0, 10);
return of({ rollbackTransaction: true, commandId: Math.floor(Math.random() * 100000) });
}
const payload = this.toBackend(draft); const payload = this.toBackend(draft);
return this.productsService.saveLoanBaseInterestRate(payload).pipe( return this.productsService.saveLoanBaseInterestRate(payload).pipe(
map((res: any) => { map((res: any) => {
const rollback = res?.rollbackTransaction === true; const rollback = res?.rollbackTransaction === true;
const commandId = res?.commandId; const commandId = res?.commandId;
if (rollback) { // Either way the server now owns this version (a pending maker-checker entry, or the new
// Keep the local Draft visible as "Pending Approval" until the checker acts. // Active one), so the local draft goes. list() brings the server copy back on its next call.
draft.status = 'Pending Approval'; this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
draft.lastModified = new Date().toISOString().slice(0, 10); this.cache = this.cache.filter((c) => c.id !== id);
} else { this.saveDrafts();
// Direct commit — drop the local draft; the server now owns the new Active version.
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
this.cache = this.cache.filter((c) => c.id !== id);
}
return { rollbackTransaction: rollback, commandId }; return { rollbackTransaction: rollback, commandId };
}),
catchError((err) => {
// HTTP failed (backend down, 4xx/5xx). Mark the draft as locally Pending so the UI isn't
// stuck on "Submitting…", and surface the failure to the caller for toast handling.
console.warn('[RateRegisterService] POST failed, keeping draft in local Pending state —', err?.message ?? err);
draft.status = 'Pending Approval';
draft.lastModified = new Date().toISOString().slice(0, 10);
return of({ rollbackTransaction: true, commandId: undefined });
}) })
// No catchError: a rejected submit (e.g. a product without GL, backend down) must reach the
// caller so it can show the reason instead of pretending the request was sent.
); );
} }
@@ -569,16 +339,51 @@ export class RateRegisterService {
} }
/** /**
* Scaffold a brand-new local Draft when the backend has no versions yet. Uses the Excel-sourced * Scaffold the first-ever Draft. Rows come from the backend's master list of particulars
* template (34 products × 10 slabs + 5 CIBIL bands) so the user has a sensible starting point to * (/loanproducts/baseinterestrates/products) with every rate blank, so the bank fills in its own
* tweak and submit as the first-ever Active version. Carries a negative id like other local drafts. * rates. If the backend is unreachable, the same standard loan types come from a built-in list.
* Carries a negative id like other local drafts.
*/ */
createFirstVersion(): RateRegister { createFirstVersion(): Observable<RateRegister> {
const template = this.seedRegisters.find((r) => r.status === 'Active') ?? this.seedRegisters[0]; const fromTemplate = (): RateRegister =>
const newId = this.nextLocalDraftId(); this.scaffoldFirstDraft(
this.defaultParticulars.map(
(p): RateChartRow => ({
srNo: p.srNo,
particulars: p.name,
glAccountId: null,
rates: new Array(this.slabs.length).fill(null)
})
),
'First version — standard loan types, rates to be filled'
);
if (!this.useHttp) {
return of(fromTemplate());
}
return this.productsService.getLoanRateRegisterProducts().pipe(
map((products: any[]) => {
const rows: RateChartRow[] = (products ?? []).map(
(p): RateChartRow => ({
srNo: p.srNo,
particulars: p.name,
glAccountId: p.glAccountId ?? null,
rates: new Array(this.slabs.length).fill(null)
})
);
return rows.length > 0
? this.scaffoldFirstDraft(rows, 'First version — standard loan types, rates to be filled')
: fromTemplate();
}),
catchError(() => of(fromTemplate()))
);
}
private scaffoldFirstDraft(rows: RateChartRow[], note: string): RateRegister {
const cibilTemplate = this.cibilSlabs;
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const draft: RateRegister = { const draft: RateRegister = {
id: newId, id: this.nextLocalDraftId(),
name: 'Interest Rate of Loans — new version', name: 'Interest Rate of Loans — new version',
effectiveFrom: today, effectiveFrom: today,
effectiveTo: null, effectiveTo: null,
@@ -586,22 +391,23 @@ export class RateRegisterService {
status: 'Draft', status: 'Draft',
createdBy: 'Current User', createdBy: 'Current User',
lastModified: today, lastModified: today,
note: 'First Loan Interest Rate Register — seeded from Excel template', note,
slabs: template.slabs, slabs: this.slabs,
rows: this.cloneRows(template.rows), rows,
cibilSlabs: this.cloneCibilSlabs(template.cibilSlabs), cibilSlabs: this.cloneCibilSlabs(cibilTemplate),
// Baseline is empty rows for the first version — compare screen will show every cell as "new". // Baseline is blank for a first version, so Compare shows every filled cell as new.
baselineRows: template.rows.map((r) => ({ baselineRows: rows.map((r) => ({
srNo: r.srNo, srNo: r.srNo,
particulars: r.particulars, particulars: r.particulars,
rates: new Array(r.rates.length).fill(null) rates: new Array(r.rates.length).fill(null)
})), })),
baselineCibilSlabs: [], baselineCibilSlabs: [],
fallbackNoCibilSlab: template.fallbackNoCibilSlab, fallbackNoCibilSlab: 2,
fallbackBelowMin: template.fallbackBelowMin fallbackBelowMin: 'block'
}; };
this.localDrafts.unshift(draft); this.localDrafts.unshift(draft);
this.cache.unshift(draft); this.cache.unshift(draft);
this.saveDrafts();
return draft; return draft;
} }
@@ -611,7 +417,7 @@ export class RateRegisterService {
* with server-assigned ids. * with server-assigned ids.
*/ */
duplicateAsDraft(sourceId: number): RateRegister | undefined { duplicateAsDraft(sourceId: number): RateRegister | undefined {
const source = this.cache.find((r) => r.id === sourceId) ?? this.seedRegisters.find((r) => r.id === sourceId); const source = this.cache.find((r) => r.id === sourceId);
if (!source) { if (!source) {
return undefined; return undefined;
} }
@@ -619,9 +425,10 @@ export class RateRegisterService {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const copy: RateRegister = { const copy: RateRegister = {
id: newId, id: newId,
name: `${source.name} — new version`, // Left blank on purpose: the maker enters name and dates in the submit popup.
effectiveFrom: source.effectiveFrom, name: '',
effectiveTo: source.effectiveTo, effectiveFrom: '',
effectiveTo: null,
currency: source.currency, currency: source.currency,
status: 'Draft', status: 'Draft',
createdBy: 'Current User', createdBy: 'Current User',
@@ -637,6 +444,7 @@ export class RateRegisterService {
}; };
this.localDrafts.unshift(copy); this.localDrafts.unshift(copy);
this.cache.unshift(copy); this.cache.unshift(copy);
this.saveDrafts();
return copy; return copy;
} }
@@ -656,76 +464,12 @@ export class RateRegisterService {
return (minId <= 0 ? minId : 0) - 1; return (minId <= 0 ? minId : 0) - 1;
} }
private row(srNo: number, particulars: string, rates: (number | null)[]): RateChartRow {
return { srNo, particulars, rates };
}
private formulaRow(srNo: number, particulars: string, rates: string[]): RateChartRow {
return { srNo, particulars, isFormula: true, rates };
}
private flat(value: number | null): (number | null)[] {
return Array(10).fill(value);
}
/**
* Build a register with its OWN deep-cloned rows and CIBIL slabs — editing one register must
* not mutate another. Optional overrides let a Draft start with real diffs vs the Active one.
*/
private seedRegister(spec: {
id: number;
name: string;
effectiveFrom: string;
effectiveTo: string | null;
status: RateRegister['status'];
createdBy: string;
lastModified: string;
note?: string;
rateOverrides?: { srNo: number; colIdx: number; value: number | string | null }[];
cibilOverrides?: { index: number; adjustmentPct: number }[];
}): RateRegister {
const rows = this.cloneRows(this.rows);
const cibilSlabs = this.cloneCibilSlabs(this.cibilSlabs);
for (const o of spec.rateOverrides ?? []) {
const r = rows.find((x) => x.srNo === o.srNo);
if (r) {
r.rates[o.colIdx] = o.value;
}
}
for (const o of spec.cibilOverrides ?? []) {
const s = cibilSlabs[o.index];
if (s) {
s.adjustmentPct = o.adjustmentPct;
}
}
return {
id: spec.id,
name: spec.name,
effectiveFrom: spec.effectiveFrom,
effectiveTo: spec.effectiveTo,
currency: 'INR',
status: spec.status,
createdBy: spec.createdBy,
lastModified: spec.lastModified,
note: spec.note,
slabs: this.slabs, // slab columns are structural; safe to share
rows,
cibilSlabs,
// Frozen baseline (last-approved state). Lives separately from `rows`/`cibilSlabs` so edits
// to the register are visible as diffs on the Compare screen, even when the register being
// edited happens to be the one that is currently Active.
baselineRows: this.cloneRows(rows),
baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs),
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
};
}
private cloneRows(src: RateChartRow[]): RateChartRow[] { private cloneRows(src: RateChartRow[]): RateChartRow[] {
return src.map((r) => ({ return src.map((r) => ({
srNo: r.srNo, srNo: r.srNo,
particulars: r.particulars, particulars: r.particulars,
isFormula: r.isFormula, isFormula: r.isFormula,
glAccountId: r.glAccountId ?? null,
rates: [...r.rates] rates: [...r.rates]
})); }));
} }
@@ -740,7 +484,7 @@ export class RateRegisterService {
// that gap. // that gap.
/** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */ /** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */
private fromBackend(d: any): RateRegister { private fromBackend(d: any, glBySrNo: Map<number, number | null>): RateRegister {
// Rebuild the slab columns from unique slabOrders. // Rebuild the slab columns from unique slabOrders.
const slabByOrder = new Map<number, SlabColumn>(); const slabByOrder = new Map<number, SlabColumn>();
for (const cell of d.slabs ?? []) { for (const cell of d.slabs ?? []) {
@@ -766,7 +510,12 @@ export class RateRegisterService {
for (const cell of d.slabs ?? []) { for (const cell of d.slabs ?? []) {
let row = rowByProduct.get(cell.productSrNo); let row = rowByProduct.get(cell.productSrNo);
if (!row) { if (!row) {
row = { srNo: cell.productSrNo, particulars: cell.productName, rates: new Array(slabs.length).fill(null) }; row = {
srNo: cell.productSrNo,
particulars: cell.productName,
glAccountId: glBySrNo.get(cell.productSrNo) ?? null,
rates: new Array(slabs.length).fill(null)
};
rowByProduct.set(cell.productSrNo, row); rowByProduct.set(cell.productSrNo, row);
} }
let value: RateCell; let value: RateCell;
@@ -851,8 +600,48 @@ export class RateRegisterService {
fallbackNoCibilSlab: r.fallbackNoCibilSlab, fallbackNoCibilSlab: r.fallbackNoCibilSlab,
fallbackBelowMin: r.fallbackBelowMin, fallbackBelowMin: r.fallbackBelowMin,
note: r.note, note: r.note,
products: r.rows.map((row) => ({
srNo: row.srNo,
name: row.particulars,
glAccountId: row.glAccountId ?? null
})),
slabs, slabs,
cibilSlabs cibilSlabs
}; };
} }
// ---- GL accounts for the particular → GL mapping ------------------------------
private glOptionsCache: GlOption[] | null = null;
/** Posting GL accounts the user can map a particular to. Cached after the first successful load. */
getGlOptions(): Observable<GlOption[]> {
if (this.glOptionsCache) {
return of(this.glOptionsCache);
}
return this.productsService.getLoanRegisterGlAccounts().pipe(
map((accounts: any[]) =>
(accounts ?? [])
// The server filters on type=1 (ASSET); this keeps asset-only even if that param is ignored.
.filter((a) => a.type == null || a.type.value === 'ASSET' || a.type.id === 1)
.sort((a, b) => String(a.glCode ?? '').localeCompare(String(b.glCode ?? '')))
.map((a) => ({
id: a.id,
label: a.glCode ? `${a.glCode} - ${a.name}` : a.name
}))
),
tap((options) => {
this.glOptionsCache = options;
}),
catchError(() => of([] as GlOption[]))
);
}
/** Display label for a mapped GL id, or null when unmapped or the account list is not loaded. */
glLabel(id: number | null | undefined): string | null {
if (id == null) {
return null;
}
return this.glOptionsCache?.find((o) => o.id === id)?.label ?? `GL #${id}`;
}
} }
@@ -0,0 +1,139 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
export interface SubmitDetailsData {
name: string;
effectiveFrom: string;
effectiveTo: string;
/** Active version this one replaces, shown as context. */
activeName?: string;
activeFrom?: string;
}
export interface SubmitDetailsResult {
name: string;
effectiveFrom: string;
effectiveTo: string;
}
/** Asks the maker for the version details (name, effective from / to) just before sending for approval. */
@Component({
selector: 'mifosx-submit-details-dialog',
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Version details</h2>
<mat-dialog-content class="sd">
<p class="sd__hint">
Confirm these details before sending the rate register for approval.
<span *ngIf="data.activeName"
>It will replace <b>{{ data.activeName }}</b
><span *ngIf="data.activeFrom"> (effective {{ data.activeFrom }})</span> once approved.</span
>
</p>
<div class="sd__field">
<label>REGISTER NAME *</label>
<input type="text" [(ngModel)]="name" />
</div>
<div class="sd__row">
<div class="sd__field">
<label>EFFECTIVE FROM *</label>
<input type="date" [(ngModel)]="effectiveFrom" />
</div>
<div class="sd__field">
<label>EFFECTIVE TO</label>
<input type="date" [(ngModel)]="effectiveTo" [min]="effectiveFrom" />
</div>
</div>
<div class="sd__error" *ngIf="error">{{ error }}</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button (click)="ref.close()">Cancel</button>
<button mat-flat-button color="primary" (click)="confirm()">Submit for approval</button>
</mat-dialog-actions>
`,
styles: [
`
.sd {
min-width: 460px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sd__hint {
margin: 0;
font-size: 12px;
color: #64748b;
}
.sd__row {
display: flex;
gap: 12px;
}
.sd__field {
display: flex;
flex-direction: column;
flex: 1;
}
.sd__field label {
font-size: 10px;
font-weight: 600;
color: #64748b;
letter-spacing: 0.3px;
margin-bottom: 3px;
}
.sd__field input {
height: 34px;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
font-family: inherit;
}
.sd__error {
font-size: 12px;
color: #b91c1c;
}
`
]
})
export class SubmitDetailsDialogComponent {
readonly ref = inject<MatDialogRef<SubmitDetailsDialogComponent, SubmitDetailsResult>>(MatDialogRef);
readonly data = inject<SubmitDetailsData>(MAT_DIALOG_DATA);
name = this.data.name;
effectiveFrom = this.data.effectiveFrom;
effectiveTo = this.data.effectiveTo;
error = '';
confirm(): void {
const name = this.name.trim();
if (!name) {
this.error = 'Enter the register name.';
return;
}
if (!this.effectiveFrom) {
this.error = 'Select the effective from date.';
return;
}
if (this.effectiveTo && this.effectiveTo < this.effectiveFrom) {
this.error = 'Effective to cannot be before effective from.';
return;
}
this.ref.close({ name, effectiveFrom: this.effectiveFrom, effectiveTo: this.effectiveTo });
}
}
@@ -75,7 +75,10 @@
INTEREST INTEREST
</ng-template> </ng-template>
<div class="lp-tab-content"> <div class="lp-tab-content">
<mifosx-loan-product-advance-interest-step></mifosx-loan-product-advance-interest-step> <mifosx-loan-product-advance-interest-step
[productSubType]="advanceSubType"
[glAccountId]="productGlAccount"
></mifosx-loan-product-advance-interest-step>
</div> </div>
</mat-tab> </mat-tab>
@@ -114,6 +117,77 @@
} }
} }
@if (isLoanAccount) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; COLLATERAL
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-security-step></mifosx-loan-product-loan-security-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
AMOUNT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-amount-tenure-step></mifosx-loan-product-loan-amount-tenure-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-calendar-alt lp-tab-icon"></i>
REPAYMENT &amp; EMI
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-repayment-step></mifosx-loan-product-loan-repayment-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-interest-step
[productSubType]="advanceSubType"
[glAccountId]="productGlAccount"
></mifosx-loan-product-loan-interest-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-flag-checkered lp-tab-icon"></i>
PREPAYMENT &amp; CLOSURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-prepayment-step></mifosx-loan-product-loan-prepayment-step>
</div>
</mat-tab>
}
<!--
OLD LOANS A/C PATH — commented out for now (replaced by the Loans A/C steps above).
Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
@if (!isAdvanceProduct) { @if (!isAdvanceProduct) {
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -178,6 +252,7 @@
</div> </div>
</mat-tab> </mat-tab>
} }
-->
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -193,6 +268,7 @@
</div> </div>
</mat-tab> </mat-tab>
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) { @if (isAdvancedPaymentStrategy) {
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -213,6 +289,7 @@
</div> </div>
</mat-tab> </mat-tab>
} }
-->
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -24,6 +24,11 @@ import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-p
import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component'; import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component';
import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component'; import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component';
import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component'; import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component';
import { LoanProductLoanSecurityStepComponent } from '../loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component';
import { LoanProductLoanAmountTenureStepComponent } from '../loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component';
import { LoanProductLoanRepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component';
import { LoanProductLoanInterestStepComponent } from '../loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component';
import { LoanProductLoanPrepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component';
/** Custom Services */ /** Custom Services */
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
@@ -63,12 +68,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
LoanProductPenalNpaStepComponent, LoanProductPenalNpaStepComponent,
LoanProductRenewalClosureStepComponent, LoanProductRenewalClosureStepComponent,
LoanProductChequeBookStepComponent, LoanProductChequeBookStepComponent,
LoanProductSettingsStepComponent, LoanProductLoanSecurityStepComponent,
LoanProductInterestRefundStepComponent, LoanProductLoanAmountTenureStepComponent,
LoanProductPaymentStrategyStepComponent, LoanProductLoanRepaymentStepComponent,
LoanProductTermsStepComponent, LoanProductLoanInterestStepComponent,
LoanProductLoanPrepaymentStepComponent,
// Old Loans A/C path — commented out for now (its tabs are commented out in the template):
// LoanProductSettingsStepComponent,
// LoanProductInterestRefundStepComponent,
// LoanProductPaymentStrategyStepComponent,
// LoanProductTermsStepComponent,
// LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductChargesStepComponent, LoanProductChargesStepComponent,
LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductAccountingStepComponent, LoanProductAccountingStepComponent,
LoanProductPreviewStepComponent LoanProductPreviewStepComponent
] ]
@@ -97,6 +108,18 @@ export class CreateLoanProductComponent implements OnInit {
loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent; loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent;
@ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent; @ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent;
// Conditional tabs (Loans A/C only): resolved after the @if renders them.
@ViewChild(LoanProductLoanSecurityStepComponent)
loanProductLoanSecurityStep: LoanProductLoanSecurityStepComponent;
@ViewChild(LoanProductLoanAmountTenureStepComponent)
loanProductLoanAmountTenureStep: LoanProductLoanAmountTenureStepComponent;
@ViewChild(LoanProductLoanRepaymentStepComponent)
loanProductLoanRepaymentStep: LoanProductLoanRepaymentStepComponent;
@ViewChild(LoanProductLoanInterestStepComponent)
loanProductLoanInterestStep: LoanProductLoanInterestStepComponent;
@ViewChild(LoanProductLoanPrepaymentStepComponent)
loanProductLoanPrepaymentStep: LoanProductLoanPrepaymentStepComponent;
/** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */ /** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */
readonly multiDisburseFallback = new UntypedFormControl(false); readonly multiDisburseFallback = new UntypedFormControl(false);
@ViewChild(LoanProductInterestRefundStepComponent, { static: true }) @ViewChild(LoanProductInterestRefundStepComponent, { static: true })
@@ -179,6 +202,11 @@ export class CreateLoanProductComponent implements OnInit {
return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft; return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft;
} }
/** The GL account chosen on the Details step; the rate register row can be matched by it. */
get productGlAccount(): number | string | null {
return this.loanProductDetailsStep?.loanProductDetailsForm?.get('glAccount')?.value ?? null;
}
/** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */ /** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */
get advanceSubType(): string { get advanceSubType(): string {
return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? ''; return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? '';
@@ -282,16 +310,26 @@ export class CreateLoanProductComponent implements OnInit {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances'; return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
} }
/** The Details step's chosen product type; drives the Loans A/C (EMI) tabs. */
get isLoanAccount(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'loans';
}
get totalTabs(): number { get totalTabs(): number {
let tabs = 4; // details, currency, charges, accounting (always) let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvanceProduct) { if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, penal & npa, renewal & closure // security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5; tabs += 5;
if (this.isOverdraftFacility) tabs += 1; // cheque book if (this.isOverdraftFacility) tabs += 1; // cheque book
} else { } else if (this.isLoanAccount) {
tabs += 2; // settings, terms // security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income tabs += 6;
} }
// Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
// else {
// tabs += 2; // settings, terms
// if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
// }
if (this.loanProductFormValid) tabs += 1; // preview if (this.loanProductFormValid) tabs += 1; // preview
return tabs; return tabs;
} }
@@ -313,12 +351,28 @@ export class CreateLoanProductComponent implements OnInit {
); );
} }
// Loans A/C: original Settings + Terms path. // Loans A/C (EMI): the curated Loans steps gate the form; Settings/Terms tabs are commented out.
const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; if (this.isLoanAccount) {
if (this.isAdvancedPaymentStrategy) { return (
return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; base &&
!!this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm?.valid &&
!!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
!!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
!!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
);
} }
return base && loansValid;
// No product type chosen yet.
return false;
// Old Loans A/C path (Settings + Terms) — commented out for now:
// const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid;
// if (this.isAdvancedPaymentStrategy) {
// return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid;
// }
// return base && loansValid;
} }
get loanProduct() { get loanProduct() {
@@ -328,7 +382,13 @@ export class CreateLoanProductComponent implements OnInit {
return this.assembleAdvanceProduct(); return this.assembleAdvanceProduct();
} }
// Loans A/C: original composition (Settings/Terms present). // Loans A/C (EMI): builds its own payload from the Loans steps, mapped onto Fineract's core fields.
if (this.isLoanAccount) {
return this.assembleLoanProduct();
}
return {};
/* OLD LOANS A/C PATH (Settings/Terms composition) — commented out for now.
const loanProduct = { const loanProduct = {
...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductDetailsStep.loanProductDetails,
...this.loanProductCurrencyStep.loanProductCurrency, ...this.loanProductCurrencyStep.loanProductCurrency,
@@ -364,6 +424,181 @@ export class CreateLoanProductComponent implements OnInit {
} }
} }
return loanProduct; return loanProduct;
*/
}
/**
* Builds the create payload for a Loans A/C (EMI) product: the Loans steps mapped onto Fineract's core
* product fields (so the standard product saves). The Loans-only options that have no core field yet
* (eligibility, collateral rules, prepayment, repayment mode, floating reset, register link) stay UI-only
* and are not sent — an unknown key would be rejected by checkForUnsupportedParameters.
*/
private assembleLoanProduct(): any {
const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const at = this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenure;
const rp = this.loanProductLoanRepaymentStep.loanProductLoanRepayment;
const intr = this.loanProductLoanInterestStep.loanProductLoanInterest;
const sec = this.loanProductLoanSecurityStep.loanProductLoanSecurity;
const pp = this.loanProductLoanPrepaymentStep.loanProductLoanPrepayment;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
const stepper = this.loanProductLoanRepaymentStep;
const interestStep = this.loanProductLoanInterestStep;
const frequency = stepper.frequencyOptions.find((f) => f.id === rp.frequency)?.fineractId ?? 2;
const daysInYear = interestStep.daysInYearOptions.find((o) => o.id === intr.daysInYear)?.fineractId ?? 1;
const daysInMonth = interestStep.daysInMonthOptions.find((o) => o.id === intr.daysInMonth)?.fineractId ?? 1;
const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
const minTenure = num(at.minTenure);
const minRate = num(intr.minRate);
const multiTranche = at.disbursementType === 'multiple';
const moratorium = num(rp.moratoriumMonths);
const payload: any = {
// identity + currency
name: d.name,
shortName: d.shortName,
description: d.description,
startDate: d.startDate,
closeDate: d.closeDate,
currencyCode: cur.currencyCode,
digitsAfterDecimal: cur.digitsAfterDecimal,
inMultiplesOf: cur.inMultiplesOf ?? 1,
// amount -> principal
minPrincipal: num(at.minAmount),
principal: num(at.defaultAmount) ?? num(at.minAmount),
maxPrincipal: num(at.maxAmount),
// tenure (months) -> number of repayments
minNumberOfRepayments: minTenure,
numberOfRepayments: num(at.defaultTenure) ?? minTenure,
maxNumberOfRepayments: num(at.maxTenure),
repaymentEvery: num(rp.repaidEvery) ?? 1,
repaymentFrequencyType: frequency,
graceOnPrincipalPayment: moratorium && moratorium > 0 ? moratorium : undefined,
// rate
minInterestRatePerPeriod: minRate,
interestRatePerPeriod: num(intr.defaultRate) ?? minRate,
maxInterestRatePerPeriod: num(intr.maxRate),
interestRateFrequencyType: 3, // per year
// amortization / calculation
amortizationType: rp.repaymentType === 'equal_principal' ? 0 : 1, // 0 equal principal, 1 equal installments
interestType: intr.calcMethod === 'flat' ? 1 : 0, // 0 declining balance, 1 flat
interestCalculationPeriodType: intr.calcPeriod === 'daily' ? 0 : 1, // 0 daily, 1 same as repayment period
transactionProcessingStrategyCode: defaultStrategy,
daysInYearType: daysInYear,
daysInMonthType: daysInMonth,
isInterestRecalculationEnabled: false,
// disbursement
multiDisburseLoan: multiTranche,
maxTrancheCount: multiTranche ? num(at.maxTranches) : undefined,
// NPA / delinquency map onto core product fields
overdueDaysForNPA: num(pn.npaDays),
graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
inArrearsTolerance: num(pn.inArrearsTolerance),
delinquencyBucketId: pn.delinquencyBucketId || undefined,
// accounting rule + GL mappings + charges (all standard, whitelisted)
...this.loanProductAccountingStep.loanProductAccounting,
...this.loanProductChargesStep.loanProductCharges,
// Details-step satellite params (same whitelist the Advances path uses)
productTypeGroup: d.productTypeGroup,
productTypeSubGroup: d.productTypeSubGroup,
glAccount: d.glAccount || undefined,
loanProductCategory: d.loanProductCategory,
prioritySector: d.prioritySector,
cibilAccountType: d.cibilAccountType,
kfsEnabled: d.kfsEnabled,
aprEnabled: d.aprEnabled,
// Loans A/C (EMI) fields kept on the product satellite (same whitelist as the Advances fields)
margin: num(sec.margin),
ltv: sec.ltv,
securityType: sec.securityType,
repaymentType: rp.repaymentType,
appropriationOrder: rp.appropriationOrder,
interestCalcMethod: intr.calcMethod,
spread: num(intr.spread),
lockInMonths: num(pp.lockInMonths),
interestOnPreclosure: pp.interestOnPreclosure,
penalApplicable: pn.penalApplicable,
penalRate: num(pn.penalRate),
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
interestAfterNpa: pn.interestAfterNpa,
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
securityMandatory: sec.securityMandatory,
guarantorsRequired: num(sec.guarantorsRequired),
coApplicant: sec.coApplicant,
thirdPartySecurity: sec.thirdPartySecurity,
legalOpinionRequired: sec.legalOpinionRequired,
propertyInsuranceRequired: sec.propertyInsuranceRequired,
registerCharge: sec.registerCharge,
releaseSecurityOnClosure: sec.autoReleaseOnClosure,
allowMultipleSecurities: sec.allowMultipleSecurities,
allowThirdPartyDeposit: sec.allowThirdPartyDeposit,
minAge: num(at.minAge),
maxAgeAtMaturity: num(at.maxAgeAtMaturity),
minCibil: num(at.minCibil),
minMonthlyIncome: num(at.minMonthlyIncome),
maxFoir: num(at.maxFoir),
maxLoansPerCustomer: num(at.maxLoansPerCustomer),
disbursementType: at.disbursementType,
disbursementMode: at.disbursementMode,
undisbursedInterest: at.undisbursedInterest,
preEmiInterest: at.preEmiInterest,
emiDueDay: rp.emiDueDay,
holidayRule: rp.holidayRule,
moratoriumMonths: num(rp.moratoriumMonths),
moratoriumInterest: rp.moratoriumInterest,
variableInstallments: rp.variableInstallments,
repaymentMode: rp.repaymentMode,
linkedAccountType: rp.linkedAccountType,
pdcCollection: rp.pdcCollection,
mandateRequired: rp.mandateRequired,
advanceEmi: rp.advanceEmi,
rateType: intr.interestType,
registerParticular: intr.registerParticular,
applyCibilAdjustment: intr.applyCibilAdjustment,
rateResolvedAt: intr.rateResolvedAt,
calcPeriod: intr.calcPeriod,
interestStartsFrom: intr.interestStartsFrom,
compounding: intr.compounding,
benchmark: intr.benchmark,
resetFrequency: intr.resetFrequency,
resetChange: intr.resetChange,
partPrepaymentAllowed: pp.partPrepaymentAllowed,
minPartPayment: num(pp.minPartPayment),
allowedOn: pp.allowedOn,
partPaymentsPerYear: pp.partPaymentsPerYear,
partPrepaymentMode: pp.partPrepaymentMode,
foreclosureFloatingIndividual: num(pp.foreclosureFloatingIndividual),
foreclosureFixedIndividual: num(pp.foreclosureFixedIndividual),
foreclosureNonIndividual: num(pp.foreclosureNonIndividual),
foreclosureTakeover: num(pp.foreclosureTakeover),
foreclosureQuoteValidDays: num(pp.foreclosureQuoteValidDays),
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
nocLetter: pp.nocLetter,
restructuring: pp.restructuring,
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
topUpAllowed: pp.topUpAllowed,
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
};
// Drop undefined keys so we never send an unresolved value.
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload;
} }
/** /**
@@ -413,7 +648,8 @@ export class CreateLoanProductComponent implements OnInit {
// rate // rate
minInterestRatePerPeriod: minRate, minInterestRatePerPeriod: minRate,
interestRatePerPeriod: minRate, // A deposit-linked row ("1% + FD") has no plain minimum or maximum, so the register step supplies the default.
interestRatePerPeriod: num(intr.defaultRate) ?? minRate,
maxInterestRatePerPeriod: num(intr.maxRate), maxInterestRatePerPeriod: num(intr.maxRate),
interestRateFrequencyType: 3, // per year interestRateFrequencyType: 3, // per year
@@ -457,6 +693,7 @@ export class CreateLoanProductComponent implements OnInit {
lienCertificatePrefix: sec.lienCertificatePrefix, lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement, printCertificateOnDisbursement: sec.printOnDisbursement,
interestBasis: intr.interestBasis, interestBasis: intr.interestBasis,
registerParticular: intr.registerParticular || undefined,
spread: num(intr.spread), spread: num(intr.spread),
interestChargedOn: intr.interestChargedOn, interestChargedOn: intr.interestChargedOn,
interestPostingFrequency: intr.postingFrequency, interestPostingFrequency: intr.postingFrequency,
@@ -172,3 +172,45 @@ $gray-900: #202124;
color: #075985; color: #075985;
line-height: 1.5; line-height: 1.5;
} }
/* Read-only reference tables (Loans A/C steps): collateral rules, foreclosure charges. */
.adv-table {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #475569;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $border;
}
td {
font-size: 14px;
padding: 10px 24px;
border-bottom: 1px solid #eef2f6;
color: #1e293b;
}
.adv-table__no {
color: #94a3b8;
}
.adv-table__yes {
color: #047857;
font-weight: 600;
}
}
.adv-type-options--3 {
grid-template-columns: repeat(3, 1fr);
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
@@ -11,36 +11,14 @@
<div class="adv-section__header"> <div class="adv-section__header">
<i class="fas fa-percent"></i> <i class="fas fa-percent"></i>
<h3>Rate</h3> <h3>Rate</h3>
<span class="adv-section__hint">Taken from the Loan Interest Rate Register</span>
</div> </div>
<div class="adv-grid adv-grid--3"> <div style="padding: 16px 0 8px">
<mat-form-field appearance="outline" class="adv-field"> <mifosx-register-rate-view
<mat-label>Interest Basis</mat-label> [productSubType]="productSubType"
<mat-select formControlName="interestBasis"> [glAccountId]="glAccountId"
@for (opt of interestBasisOptions; track opt.id) { (selectionChange)="onRegisterSelection($event)"
<mat-option [value]="opt.id">{{ opt.label }}</mat-option> ></mifosx-register-rate-view>
}
</mat-select>
</mat-form-field>
@if (showSpread) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Spread over Deposit Rate (%)</mat-label>
<input matInput type="number" formControlName="spread" min="0" max="20" step="0.01" />
<mat-hint>Advance rate = deposit rate + spread</mat-hint>
</mat-form-field>
} @else {
<div class="adv-field"></div>
}
<div class="adv-field"></div>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="minRate" min="0" step="0.01" required />
<mat-error>Minimum Rate is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="maxRate" min="0" step="0.01" required />
<mat-error>Maximum Rate is <strong>required</strong></mat-error>
</mat-form-field>
</div> </div>
</div> </div>
@@ -6,9 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, inject } from '@angular/core'; import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component';
/** /**
* Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds, * Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds,
@@ -18,13 +19,23 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
selector: 'mifosx-loan-product-advance-interest-step', selector: 'mifosx-loan-product-advance-interest-step',
templateUrl: './loan-product-advance-interest-step.component.html', templateUrl: './loan-product-advance-interest-step.component.html',
styleUrls: ['../advance-steps.scss'], styleUrls: ['../advance-steps.scss'],
imports: [...STANDALONE_SHARED_IMPORTS] imports: [
...STANDALONE_SHARED_IMPORTS,
RegisterRateViewComponent
]
}) })
export class LoanProductAdvanceInterestStepComponent { export class LoanProductAdvanceInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceInterestForm: UntypedFormGroup; loanProductAdvanceInterestForm: UntypedFormGroup;
/** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */
@Input() productSubType = '';
@Input() glAccountId: number | string | null = null;
/** The register row the product takes its rate from; null while the register has no row for it. */
registerSelection: RegisterRateSelection | null = null;
readonly interestBasisOptions = [ readonly interestBasisOptions = [
{ id: 'deposit_spread', label: 'Deposit Rate + Spread' }, { id: 'deposit_spread', label: 'Deposit Rate + Spread' },
{ id: 'fixed', label: 'Fixed rate' }, { id: 'fixed', label: 'Fixed rate' },
@@ -77,11 +88,12 @@ export class LoanProductAdvanceInterestStepComponent {
Validators.max(20) Validators.max(20)
] ]
], ],
minRate: [ // Filled from the register row; never typed in.
'', minRate: [''],
Validators.required maxRate: [''],
], defaultRate: [''],
maxRate: [ // The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created.
registerParticular: [
'', '',
Validators.required Validators.required
], ],
@@ -95,6 +107,33 @@ export class LoanProductAdvanceInterestStepComponent {
}); });
} }
/**
* The register row decides the rate. A row of plain rates fixes the minimum and maximum; a deposit row
* ("1% + FD") is the deposit rate plus a spread, which has no plain minimum or maximum. With no row for the
* product the register particular stays empty, which keeps this step, and so the whole product, invalid until
* the product has been added to the register.
*/
onRegisterSelection(selection: RegisterRateSelection | null): void {
this.registerSelection = selection;
const form = this.loanProductAdvanceInterestForm;
if (selection) {
form.patchValue(
{
interestBasis: selection.isFormula ? 'deposit_spread' : 'fixed',
spread: selection.isFormula ? selection.minSpread : 0,
minRate: selection.minRate ?? '',
maxRate: selection.maxRate ?? '',
defaultRate: selection.defaultRate ?? '',
registerParticular: selection.particular
},
{ emitEvent: false }
);
} else {
form.patchValue({ minRate: '', maxRate: '', defaultRate: '', registerParticular: '' }, { emitEvent: false });
}
form.updateValueAndValidity({ emitEvent: false });
}
get showSpread(): boolean { get showSpread(): boolean {
return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread'; return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread';
} }
@@ -17,6 +17,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types';
@Component({ @Component({
selector: 'mifosx-loan-product-details-step', selector: 'mifosx-loan-product-details-step',
@@ -110,35 +111,10 @@ export class LoanProductDetailsStepComponent implements OnInit {
isExistingProduct = false; isExistingProduct = false;
/** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */ /** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */
readonly productTypeGroups = [ /** Category and type lists are shared with the product list and the loan account product picker. */
{ id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, readonly productTypeGroups = PRODUCT_TYPE_GROUPS;
{ id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' }
];
/** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ readonly productTypeSubGroups = PRODUCT_SUB_TYPES;
readonly productTypeSubGroups: Record<string, { id: string; label: string; shortName: string }[]> = {
advances: [
{ id: 'fd', label: 'Loan against FD', shortName: 'FD' },
{ id: 'rd', label: 'Loan against RD', shortName: 'RD' },
{ id: 'dds', label: 'Loan against DDS', shortName: 'DS' },
{ id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' },
{ id: 'gold', label: 'Loan against Gold', shortName: 'GD' },
{ id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' },
{ id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' },
{ id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' },
{ id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' },
{ id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' }
],
loans: [
{ id: 'term', label: 'Term Loan', shortName: 'TL' },
{ id: 'personal', label: 'Personal Loan', shortName: 'PL' },
{ id: 'housing', label: 'Housing Loan', shortName: 'HL' },
{ id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' },
{ id: 'education', label: 'Education Loan', shortName: 'EL' },
{ id: 'business', label: 'Business / MSME Loan', shortName: 'BL' },
{ id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' }
]
};
/** Regulatory Classification options (UI-only; RBI / bureau reporting). */ /** Regulatory Classification options (UI-only; RBI / bureau reporting). */
readonly loanProductCategoryOptions = [ readonly loanProductCategoryOptions = [
@@ -0,0 +1,150 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanAmountTenureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-coins"></i>
<h3>Loan Amount</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Amount (₹)</mat-label>
<input matInput type="number" formControlName="minAmount" min="0" required />
<mat-error>Minimum Amount is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Amount (₹)</mat-label>
<input matInput type="number" formControlName="defaultAmount" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Amount (₹)</mat-label>
<input matInput type="number" formControlName="maxAmount" min="0" required />
<mat-error>Maximum Amount is <strong>required</strong></mat-error>
</mat-form-field>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Each loan is sanctioned within these bounds. The amount is fixed at sanction. It is not a limit that can be
drawn and repaid again.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Tenure</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" min="1" required />
<mat-error>Minimum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" min="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" min="1" required />
<mat-hint>Age at maturity cap comes from Borrower Eligibility below</mat-hint>
<mat-error>Maximum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user"></i>
<h3>Borrower Eligibility</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Age (years)</mat-label>
<input matInput type="number" formControlName="minAge" min="18" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Age at Maturity (years)</mat-label>
<input matInput type="number" formControlName="maxAgeAtMaturity" min="18" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum CIBIL Score</mat-label>
<input matInput type="number" formControlName="minCibil" min="300" max="900" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Monthly Income (₹)</mat-label>
<input matInput type="number" formControlName="minMonthlyIncome" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum FOIR / DBR (%)</mat-label>
<input matInput type="number" formControlName="maxFoir" min="0" max="100" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Loans per Customer</mat-label>
<input matInput type="number" formControlName="maxLoansPerCustomer" min="1" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrow-right"></i>
<h3>Disbursement</h3>
</div>
<mat-radio-group formControlName="disbursementType" class="adv-type-options">
<mat-radio-button value="single" class="adv-type-option">
<span class="adv-type-option__label">Single disbursement</span>
<span class="adv-type-option__hint">Full amount paid out once.</span>
</mat-radio-button>
<mat-radio-button value="multiple" class="adv-type-option">
<span class="adv-type-option__label">Multiple tranches</span>
<span class="adv-type-option__hint">Paid out in stages, for example as construction progresses.</span>
</mat-radio-button>
</mat-radio-group>
<div class="adv-grid adv-grid--3">
@if (isMultiTranche) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tranches</mat-label>
<input matInput type="number" formControlName="maxTranches" min="2" />
<mat-error>At least <strong>2</strong> tranches</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Disbursement Mode</mat-label>
<mat-select formControlName="disbursementMode">
@for (opt of disbursementModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Undisbursed Amount</mat-label>
<mat-select formControlName="undisbursedInterest">
@for (opt of undisbursedInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="preEmiInterest">
Pre-EMI interest is charged on each disbursed tranche until the first full EMI
</mat-checkbox>
</div>
</div>
<div class="adv-note">
<i class="fas fa-info-circle"></i>
<span>
<strong>Hidden for Loans A/C:</strong> Facility Type (Demand / Overdraft), Drawing power, Limit renewal, Cheque
book.
</span>
</div>
</form>
@@ -0,0 +1,106 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure,
* borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product
* fields; eligibility is UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-amount-tenure-step',
templateUrl: './loan-product-loan-amount-tenure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanAmountTenureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanAmountTenureForm: UntypedFormGroup;
readonly disbursementModeOptions = [
{ id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' },
{ id: 'transfer', label: 'Transfer to A/C only' },
{ id: 'dd', label: 'Demand draft only' }
];
readonly undisbursedInterestOptions = [
{ id: 'none', label: 'None — interest only on disbursed' },
{ id: 'commitment', label: 'Commitment charge on undisbursed' }
];
constructor() {
this.loanProductLoanAmountTenureForm = this.formBuilder.group({
minAmount: [
'',
[
Validators.required,
Validators.min(0)
]
],
defaultAmount: [''],
maxAmount: [
'',
[
Validators.required,
Validators.min(0)
]
],
minTenure: [
12,
[
Validators.required,
Validators.min(1)
]
],
defaultTenure: [
'',
Validators.min(1)
],
maxTenure: [
360,
[
Validators.required,
Validators.min(1)
]
],
minAge: [21],
maxAgeAtMaturity: [70],
minCibil: [650],
minMonthlyIncome: [''],
maxFoir: [50],
maxLoansPerCustomer: [1],
disbursementType: ['single'],
maxTranches: [
2,
Validators.min(2)
],
disbursementMode: ['transfer_dd_vendor'],
undisbursedInterest: ['none'],
preEmiInterest: [true]
});
}
get isMultiTranche(): boolean {
return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple';
}
get loanProductLoanAmountTenure() {
return this.loanProductLoanAmountTenureForm.getRawValue();
}
}
@@ -0,0 +1,161 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanInterestForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Interest Type</h3>
</div>
<mat-radio-group formControlName="interestType" class="adv-type-options adv-type-options--3">
<mat-radio-button value="fixed" class="adv-type-option">
<span class="adv-type-option__label">Fixed</span>
<span class="adv-type-option__hint">Same rate for the whole tenure</span>
</mat-radio-button>
<mat-radio-button value="floating" class="adv-type-option">
<span class="adv-type-option__label">Floating</span>
<span class="adv-type-option__hint">Linked to a benchmark; resets periodically</span>
</mat-radio-button>
<mat-radio-button value="hybrid" class="adv-type-option">
<span class="adv-type-option__label">Hybrid</span>
<span class="adv-type-option__hint">Fixed for a period, then floating</span>
</mat-radio-button>
</mat-radio-group>
<div style="height: 20px"></div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table-cells"></i>
<h3>Rate Source</h3>
<span class="adv-section__hint">Centralised — changes flow to all loans on this product</span>
</div>
<div style="padding: 16px 0 8px">
<mifosx-register-rate-view
[productSubType]="productSubType"
[glAccountId]="glAccountId"
(selectionChange)="onRegisterSelection($event)"
></mifosx-register-rate-view>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Apply CIBIL Adjustment</mat-label>
<mat-select formControlName="applyCibilAdjustment">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Spread over Register Rate (%)</mat-label>
<input matInput type="number" formControlName="spread" min="0" max="20" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Rate Resolved At</mat-label>
<mat-select formControlName="rateResolvedAt">
@for (opt of rateResolvedAtOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-chart-simple"></i>
<h3>Calculation</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Method</mat-label>
<mat-select formControlName="calcMethod">
@for (opt of calcMethodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="daysInYear">
@for (opt of daysInYearOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Month</mat-label>
<mat-select formControlName="daysInMonth">
@for (opt of daysInMonthOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Period</mat-label>
<mat-select formControlName="calcPeriod">
@for (opt of calcPeriodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Starts From</mat-label>
<mat-select formControlName="interestStartsFrom">
@for (opt of interestStartOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Compounding</mat-label>
<mat-select formControlName="compounding">
@for (opt of compoundingOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (isFloatingOrHybrid) {
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-rotate"></i>
<h3>Floating Rate Reset</h3>
<span class="adv-section__hint">Shown only when the type is Floating or Hybrid</span>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Benchmark</mat-label>
<mat-select formControlName="benchmark">
@for (opt of benchmarkOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reset Frequency</mat-label>
<mat-select formControlName="resetFrequency">
@for (opt of resetFrequencyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>On Reset, Change</mat-label>
<mat-select formControlName="resetChange">
@for (opt of resetChangeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form>
@@ -0,0 +1,188 @@
/**
* 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, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component';
/**
* Interest step for a Loans A/C (EMI) product (step 6). The rate normally comes from the central
* Loan Interest Rate Register (amount slab + CIBIL adjustment); the min / default / max rate and the
* calculation method feed Fineract's core product fields. Register link, spread and floating reset
* are UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-interest-step',
templateUrl: './loan-product-loan-interest-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
RegisterRateViewComponent
]
})
export class LoanProductLoanInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanInterestForm: UntypedFormGroup;
/** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */
@Input() productSubType = '';
@Input() glAccountId: number | string | null = null;
/** The register row the product takes its rate from; null while the register has no row for it. */
registerSelection: RegisterRateSelection | null = null;
/** The 16 default particulars of the Loan Interest Rate Register. */
readonly registerParticulars = [
'Housing Loan (Purchase/Construction)',
'Home Repair/Renovation',
'Personal Loan (Unsecured)',
'Personal Loan (Secured/Property)',
'Two Wheeler Loan',
'Car Loan (New)',
'Used Car/Vehicle Loan',
'Commercial Vehicle Loan',
'Gold Loan',
'Business Term Loan',
'Machinery & Equipment Loan',
'Cash Credit',
'Overdraft',
'Agriculture Loan',
'Loan against FDR',
'Loan against RD'
];
readonly yesNoOptions = [
'YES',
'NO'
];
readonly rateResolvedAtOptions = [
{ id: 'sanction', label: 'Sanction' },
{ id: 'disbursement', label: 'First disbursement' }
];
readonly calcMethodOptions = [
{ id: 'declining', label: 'Declining Balance' },
{ id: 'flat', label: 'Flat' }
];
/** `fineractId` maps to Fineract's daysInYearType (1 = actual). */
readonly daysInYearOptions = [
{ id: 'actual', label: 'Actual (365 / 366)', fineractId: 1 },
{ id: '365', label: '365', fineractId: 365 },
{ id: '360', label: '360', fineractId: 360 }
];
/** `fineractId` maps to Fineract's daysInMonthType (1 = actual). */
readonly daysInMonthOptions = [
{ id: 'actual', label: 'Actual', fineractId: 1 },
{ id: '30', label: '30', fineractId: 30 }
];
readonly calcPeriodOptions = [
{ id: 'same', label: 'Same as repayment period' },
{ id: 'daily', label: 'Daily' }
];
readonly interestStartOptions = [
{ id: 'disbursement', label: 'Disbursement date' },
{ id: 'first_emi', label: 'First EMI date' }
];
readonly compoundingOptions = [
{ id: 'none', label: 'None (simple on outstanding)' },
{ id: 'monthly', label: 'Monthly' }
];
readonly benchmarkOptions = [
{ id: 'repo', label: 'Repo / MCLR / Bank base rate' },
{ id: 'eblr', label: 'External benchmark (EBLR)' }
];
readonly resetFrequencyOptions = [
{ id: 'quarterly', label: 'Quarterly' },
{ id: 'half_yearly', label: 'Half-yearly' },
{ id: 'yearly', label: 'Yearly' }
];
readonly resetChangeOptions = [
{ id: 'tenure', label: 'Tenure (EMI stays constant)' },
{ id: 'emi', label: 'EMI (tenure stays constant)' }
];
constructor() {
this.loanProductLoanInterestForm = this.formBuilder.group({
interestType: ['fixed'],
// The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created.
registerParticular: [
'',
Validators.required
],
applyCibilAdjustment: ['YES'],
spread: [
0,
[
Validators.min(0),
Validators.max(20)
]
],
rateResolvedAt: ['sanction'],
// Filled from the register row; never typed in.
minRate: [''],
defaultRate: [''],
maxRate: [''],
calcMethod: ['declining'],
daysInYear: ['actual'],
daysInMonth: ['actual'],
calcPeriod: ['same'],
interestStartsFrom: ['disbursement'],
compounding: ['none'],
benchmark: ['repo'],
resetFrequency: ['quarterly'],
resetChange: ['tenure']
});
}
/**
* The register row decides the rate limits. With no row for the product the register particular stays empty,
* which keeps this step, and so the whole product, invalid until the product has been added to the register.
*/
onRegisterSelection(selection: RegisterRateSelection | null): void {
this.registerSelection = selection;
const form = this.loanProductLoanInterestForm;
if (selection) {
form.patchValue(
{
registerParticular: selection.particular,
minRate: selection.minRate ?? '',
maxRate: selection.maxRate ?? '',
defaultRate: selection.defaultRate ?? ''
},
{ emitEvent: false }
);
} else {
form.patchValue({ registerParticular: '', minRate: '', maxRate: '', defaultRate: '' }, { emitEvent: false });
}
form.updateValueAndValidity({ emitEvent: false });
}
/** Reset options only apply to a floating or hybrid rate. */
get isFloatingOrHybrid(): boolean {
const type = this.loanProductLoanInterestForm.get('interestType').value;
return type === 'floating' || type === 'hybrid';
}
get loanProductLoanInterest() {
return this.loanProductLoanInterestForm.getRawValue();
}
}
@@ -0,0 +1,144 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanPrepaymentForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrow-down"></i>
<h3>Part-prepayment</h3>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="partPrepaymentAllowed">Part-prepayment allowed</mat-checkbox>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Part-payment (₹)</mat-label>
<input matInput type="number" formControlName="minPartPayment" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allowed Only On</mat-label>
<mat-select formControlName="allowedOn">
@for (opt of allowedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Part-payments per Year</mat-label>
<mat-select formControlName="partPaymentsPerYear">
@for (opt of perYearOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<mat-radio-group formControlName="partPrepaymentMode" class="adv-type-options adv-type-options--3">
<mat-radio-button value="reduce_tenure" class="adv-type-option">
<span class="adv-type-option__label">Reduce tenure</span>
<span class="adv-type-option__hint">EMI stays the same</span>
</mat-radio-button>
<mat-radio-button value="reduce_emi" class="adv-type-option">
<span class="adv-type-option__label">Reduce EMI</span>
<span class="adv-type-option__hint">Tenure stays the same</span>
</mat-radio-button>
<mat-radio-button value="borrower_chooses" class="adv-type-option">
<span class="adv-type-option__label">Borrower chooses</span>
<span class="adv-type-option__hint">Picked at payment time</span>
</mat-radio-button>
</mat-radio-group>
<div style="height: 20px"></div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-lock"></i>
<h3>Pre-closure (Foreclosure)</h3>
<span class="adv-section__hint">Charge on outstanding principal</span>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · floating rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFloatingIndividual" min="0" max="100" step="0.01" />
<mat-hint>Nil as per RBI</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · fixed rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFixedIndividual" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Non-individual / business (%)</mat-label>
<input matInput type="number" formControlName="foreclosureNonIndividual" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Takeover by another bank (%)</mat-label>
<input matInput type="number" formControlName="foreclosureTakeover" min="0" max="100" step="0.01" />
</mat-form-field>
</div>
<div class="adv-grid adv-grid--3" style="padding-top: 0">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Lock-in Period (months)</mat-label>
<input matInput type="number" formControlName="lockInMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Pre-closure</mat-label>
<mat-select formControlName="interestOnPreclosure">
@for (opt of interestOnPreclosureOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Foreclosure Quote Valid (days)</mat-label>
<input matInput type="number" formControlName="foreclosureQuoteValidDays" min="1" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-flag-checkered"></i>
<h3>Closure &amp; Restructuring</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Auto-close on Zero Balance</mat-label>
<mat-select formControlName="autoCloseOnZeroBalance">
@for (opt of autoCloseOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NOC / Closure Letter</mat-label>
<mat-select formControlName="nocLetter">
@for (opt of nocOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Restructuring / Re-schedule</mat-label>
<mat-select formControlName="restructuring">
@for (opt of restructuringOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="releaseCollateralOnClosure">Release collateral and lien on closure</mat-checkbox>
<mat-checkbox formControlName="topUpAllowed">Top-up loan on the same account</mat-checkbox>
<mat-checkbox formControlName="balanceTransferOutAllowed">Allow balance transfer (takeover) out</mat-checkbox>
</div>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">Unlike an Advance, there is no renewal. A loan ends at maturity or on pre-closure.</div>
</div>
</div>
</form>
@@ -0,0 +1,117 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Prepayment & Closure step for a Loans A/C (EMI) product (step 8). A loan has no renewal; it ends at
* maturity or on pre-closure. UI-only for now: not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-loan-prepayment-step',
templateUrl: './loan-product-loan-prepayment-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanPrepaymentStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanPrepaymentForm: UntypedFormGroup;
readonly allowedOnOptions = [
{ id: 'emi_date', label: 'EMI due date' },
{ id: 'any_day', label: 'Any day' }
];
readonly perYearOptions = [
{ id: 'unlimited', label: 'Unlimited' },
{ id: 'one', label: 'Once a year' },
{ id: 'two', label: 'Twice a year' }
];
readonly interestOnPreclosureOptions = [
{ id: 'till_date', label: 'Till date of payment' },
{ id: 'full_month', label: 'Full current month' }
];
readonly autoCloseOptions = [
'YES',
'NO'
];
readonly nocOptions = [
{ id: 'auto', label: 'Auto-generate' },
{ id: 'manual', label: 'On request' }
];
readonly restructuringOptions = [
{ id: 'approval', label: 'Allowed with approval' },
{ id: 'no', label: 'Not allowed' }
];
constructor() {
const percent = [
Validators.min(0),
Validators.max(100)
];
this.loanProductLoanPrepaymentForm = this.formBuilder.group({
partPrepaymentAllowed: [true],
minPartPayment: [
'',
Validators.min(0)
],
allowedOn: ['emi_date'],
partPaymentsPerYear: ['unlimited'],
partPrepaymentMode: ['reduce_tenure'],
foreclosureFloatingIndividual: [
0,
percent
],
foreclosureFixedIndividual: [
2,
percent
],
foreclosureNonIndividual: [
3,
percent
],
foreclosureTakeover: [
2,
percent
],
lockInMonths: [
0,
Validators.min(0)
],
interestOnPreclosure: ['till_date'],
foreclosureQuoteValidDays: [
7,
Validators.min(1)
],
autoCloseOnZeroBalance: ['YES'],
nocLetter: ['auto'],
restructuring: ['approval'],
releaseCollateralOnClosure: [true],
topUpAllowed: [false],
balanceTransferOutAllowed: [true]
});
}
get loanProductLoanPrepayment() {
return this.loanProductLoanPrepaymentForm.getRawValue();
}
}
@@ -0,0 +1,155 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanRepaymentForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-wave-square"></i>
<h3>Repayment Type</h3>
</div>
<mat-radio-group formControlName="repaymentType" class="adv-type-options adv-type-options--3">
@for (opt of repaymentTypeOptions; track opt.id) {
<mat-radio-button [value]="opt.id" class="adv-type-option">
<span class="adv-type-option__label">{{ opt.label }}</span>
<span class="adv-type-option__hint">{{ opt.hint }}</span>
</mat-radio-button>
}
</mat-radio-group>
<div style="padding: 16px 24px 20px">
<div class="adv-info">
This is the main difference from an Advance. Repayment follows a fixed schedule, not a running balance.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-calendar-alt"></i>
<h3>Schedule</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repaid Every</mat-label>
<input matInput type="number" formControlName="repaidEvery" min="1" required />
<mat-error>Repaid Every is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Frequency</mat-label>
<mat-select formControlName="frequency">
@for (opt of frequencyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Days to First Repayment</mat-label>
<input matInput type="number" formControlName="minDaysToFirstRepayment" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>EMI Due Day</mat-label>
<mat-select formControlName="emiDueDay">
@for (opt of emiDueDayOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>If Due Date is a Holiday</mat-label>
<mat-select formControlName="holidayRule">
@for (opt of holidayRuleOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Moratorium / Grace (months)</mat-label>
<input matInput type="number" formControlName="moratoriumMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest during Moratorium</mat-label>
<mat-select formControlName="moratoriumInterest">
@for (opt of moratoriumInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="variableInstallments">Variable installments allowed</mat-checkbox>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-money-check"></i>
<h3>Repayment Mode</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repayment Mode</mat-label>
<mat-select formControlName="repaymentMode">
@for (opt of repaymentModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PDC Collection</mat-label>
<mat-select formControlName="pdcCollection">
@for (opt of pdcOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is
therefore not part of a Loan A/C.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-list-ol"></i>
<h3>Appropriation Order</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Appropriation Order</mat-label>
<mat-select formControlName="appropriationOrder">
@for (opt of appropriationOrderOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Advance EMI Payment</mat-label>
<mat-select formControlName="advanceEmi">
@for (opt of advanceEmiOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</form>
@@ -0,0 +1,129 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Repayment & EMI step for a Loans A/C (EMI) product (step 5) — the step an Advance does not have.
* Repayment type and frequency feed Fineract's core product fields; the repayment mode and the
* rest of the schedule options are UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-repayment-step',
templateUrl: './loan-product-loan-repayment-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanRepaymentStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanRepaymentForm: UntypedFormGroup;
readonly repaymentTypeOptions = [
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' },
{ id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' },
{ id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' }
];
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
readonly frequencyOptions = [
{ id: 'months', label: 'Months', fineractId: 2 },
{ id: 'weeks', label: 'Weeks', fineractId: 1 },
{ id: 'days', label: 'Days', fineractId: 0 }
];
readonly emiDueDayOptions = [
{ id: 'disbursement_day', label: 'Same day as disbursement' },
{ id: 'fixed_day', label: 'Fixed day of month (5th)' }
];
readonly holidayRuleOptions = [
{ id: 'next', label: 'Next working day' },
{ id: 'previous', label: 'Previous working day' },
{ id: 'same', label: 'Same day' }
];
readonly moratoriumInterestOptions = [
{ id: 'pre_emi', label: 'Pay monthly (Pre-EMI)' },
{ id: 'capitalise', label: 'Capitalise into loan' }
];
readonly repaymentModeOptions = [
{ id: 'nach', label: 'Auto-debit (NACH / ECS)' },
{ id: 'pdc', label: 'Post-dated cheques (PDC)' },
{ id: 'cash', label: 'Cash / transfer at branch' }
];
readonly linkedAccountOptions = [
{ id: 'savings_current', label: 'Savings / Current' },
{ id: 'savings', label: 'Savings only' }
];
readonly pdcOptions = [
{ id: 'mandatory', label: 'Mandatory — 3 cheques' },
{ id: 'optional', label: 'Optional' },
{ id: 'none', label: 'Not collected' }
];
readonly appropriationOrderOptions = [
{ id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' },
{ id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' }
];
readonly advanceEmiOptions = [
{ id: 'adjust_next', label: 'Allowed — adjust next EMI' },
{ id: 'not_allowed', label: 'Not allowed' }
];
constructor() {
this.loanProductLoanRepaymentForm = this.formBuilder.group({
repaymentType: ['emi'],
repaidEvery: [
1,
[
Validators.required,
Validators.min(1)
]
],
frequency: ['months'],
minDaysToFirstRepayment: [
'',
Validators.min(0)
],
emiDueDay: ['fixed_day'],
holidayRule: ['next'],
moratoriumMonths: [
0,
Validators.min(0)
],
moratoriumInterest: ['pre_emi'],
variableInstallments: [false],
repaymentMode: ['nach'],
linkedAccountType: ['savings_current'],
pdcCollection: ['mandatory'],
mandateRequired: [true],
appropriationOrder: ['charges_penal_interest_principal'],
advanceEmi: ['adjust_next']
});
}
get loanProductLoanRepayment() {
return this.loanProductLoanRepaymentForm.getRawValue();
}
}
@@ -0,0 +1,130 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanSecurityForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-shield-alt"></i>
<h3>Security</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Security Type</mat-label>
<input matInput formControlName="securityType" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Security Mandatory</mat-label>
<mat-select formControlName="securityMandatory">
@for (opt of securityMandatoryOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
<mat-error>Margin must be between <strong>0 and 100</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum LTV (%)</mat-label>
<input matInput [value]="ltv" readonly />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table"></i>
<h3>Collateral Rules by Security Type</h3>
<span class="adv-section__hint">Which security this product may accept</span>
</div>
<table class="adv-table">
<thead>
<tr>
<th>Security</th>
<th>Allowed</th>
<th>Max LTV %</th>
<th>Valuation</th>
<th>Insurance</th>
<th>Charge / lien</th>
</tr>
</thead>
<tbody>
@for (rule of collateralRules; track rule.security) {
<tr>
<td>{{ rule.security }}</td>
<td [class.adv-table__yes]="rule.allowed" [class.adv-table__no]="!rule.allowed">
{{ rule.allowed ? 'Yes' : 'No' }}
</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.ltv }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.valuation }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.insurance }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.charge }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-plus"></i>
<h3>Guarantor &amp; Co-applicant</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Guarantors Required (min)</mat-label>
<input matInput type="number" formControlName="guarantorsRequired" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Co-applicant</mat-label>
<mat-select formControlName="coApplicant">
@for (opt of coApplicantOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-check-circle"></i>
<h3>Conditions</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="legalOpinionRequired">
Legal opinion / title search mandatory before sanction
</mat-checkbox>
<mat-checkbox formControlName="propertyInsuranceRequired">
Property insurance mandatory for the whole tenure
</mat-checkbox>
<mat-checkbox formControlName="registerCharge">Register charge (CERSAI) before disbursement</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">Release security automatically on closure</mat-checkbox>
<mat-checkbox formControlName="allowMultipleSecurities">Allow multiple securities for one loan</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">Allow third-party deposit as security</mat-checkbox>
</div>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Unlike an Advance, the security does not decide a running limit. It only decides eligibility at sanction.
Disbursement happens once, up to the sanctioned amount.
</div>
</div>
</div>
</form>
@@ -0,0 +1,127 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Security & Collateral step for a Loans A/C (EMI) product (step 3).
*
* Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
* limit. UI-only for now: not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-loan-security-step',
templateUrl: './loan-product-loan-security-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductLoanSecurityStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanSecurityForm: UntypedFormGroup;
readonly securityMandatoryOptions = [
{ id: 'yes', label: 'Yes — no sanction without security' },
{ id: 'no', label: 'No — unsecured' }
];
readonly coApplicantOptions = [
{ id: 'joint_property', label: 'Mandatory for joint property' },
{ id: 'always', label: 'Always mandatory' },
{ id: 'optional', label: 'Optional' }
];
readonly thirdPartyOptions = [
{ id: 'consent', label: 'Allowed with owner consent' },
{ id: 'no', label: 'Not allowed' }
];
/** Reference table: which security a loan on this product may accept. */
readonly collateralRules = [
{
security: 'Residential property',
allowed: true,
ltv: '80',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Commercial property',
allowed: true,
ltv: '70',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Fixed / Recurring Deposit',
allowed: true,
ltv: '90',
valuation: 'Not needed',
insurance: 'Not needed',
charge: 'Lien marked'
},
{
security: 'LIC policy',
allowed: true,
ltv: '90',
valuation: 'Surrender value',
insurance: 'Not needed',
charge: 'Assignment'
},
{ security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' },
{ security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }
];
constructor() {
this.loanProductLoanSecurityForm = this.formBuilder.group({
securityType: ['Immovable property (mortgage)'],
securityMandatory: ['yes'],
margin: [
20,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
guarantorsRequired: [
1,
Validators.min(0)
],
coApplicant: ['joint_property'],
thirdPartySecurity: ['consent'],
legalOpinionRequired: [true],
propertyInsuranceRequired: [true],
registerCharge: [true],
autoReleaseOnClosure: [true],
allowMultipleSecurities: [false],
allowThirdPartyDeposit: [false]
});
}
/** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
get ltv(): number {
const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
return Number.isFinite(margin) ? Math.max(0, 100 - margin) : 100;
}
get loanProductLoanSecurity() {
return {
...this.loanProductLoanSecurityForm.getRawValue(),
ltv: this.ltv
};
}
}
@@ -0,0 +1,97 @@
<!--
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/.
-->
@switch (state) {
@case ('loading') {
<div class="adv-info">Reading the Loan Interest Rate Register…</div>
}
@case ('none') {
<div class="rrv__block">
<strong>There is no active Loan Interest Rate Register yet.</strong>
<span>
A product takes its interest rate from the register, so it cannot be created until a register is created and
approved.
</span>
<a routerLink="/products/loan-interest-rate-register" class="rrv__block-btn">Open the register →</a>
</div>
}
@case ('ready') {
@if (selectedRow) {
<div class="adv-info rrv__source">
<span>
Interest comes from the active register <strong>{{ register?.name }}</strong
>, effective {{ register?.effectiveFrom }}.
</span>
<a routerLink="/products/loan-interest-rate-register">Open register →</a>
</div>
}
@if (!selectedRow) {
<div class="rrv__block">
<strong>This product has no interest rate in the Loan Interest Rate Register yet.</strong>
<span>{{ blockReason }}</span>
<a routerLink="/products/loan-interest-rate-register" class="rrv__block-btn">Add it in the register →</a>
</div>
}
@if (selectedRow) {
<div class="rrv__row">
<span class="rrv__row-label">Register row</span>
<strong class="rrv__row-name">{{ selectedRow.srNo }}. {{ selectedRow.particulars }}</strong>
@if (glLabel(selectedRow); as gl) {
<span class="rrv__row-gl">{{ gl }}</span>
}
<span class="rrv__row-note">{{ matchNote }}</span>
</div>
<div class="rrv__tables">
<table class="adv-table">
<thead>
<tr>
<th>Amount slab</th>
<th>Rate</th>
</tr>
</thead>
<tbody>
@for (slab of slabRates; track slab.label) {
<tr>
<td>{{ slab.label }}</td>
<td [class.adv-table__no]="slab.na" [class.rrv__formula]="slab.formula">{{ slab.text }}</td>
</tr>
}
</tbody>
</table>
<table class="adv-table">
<thead>
<tr>
<th>CIBIL score</th>
<th>Band</th>
<th>Added to the rate</th>
</tr>
</thead>
<tbody>
@for (slab of cibilSlabs; track slab.from) {
<tr>
<td>{{ slab.from }} – {{ slab.to }}</td>
<td>{{ slab.tier }}</td>
<td>{{ slab.adjustmentPct > 0 ? '+' + slab.adjustmentPct.toFixed(2) + '%' : 'No change' }}</td>
</tr>
}
</tbody>
</table>
</div>
@if (selectedRow.isFormula || slabRates[0]?.formula) {
<div class="rrv__note">
This row is the deposit's own rate plus a spread, so the final rate is worked out on each account from its
deposit.
</div>
}
}
}
}
@@ -0,0 +1,106 @@
/**
* 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;
}
.rrv__source {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
a {
font-weight: 600;
white-space: nowrap;
}
}
.rrv__block {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 16px;
margin: 16px 24px 0;
padding: 12px 16px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
line-height: 1.5;
strong {
flex: 1 1 100%;
font-size: 14px;
}
span {
flex: 1 1 360px;
}
}
.rrv__block-btn {
font-weight: 600;
color: #991b1b;
white-space: nowrap;
}
.rrv__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 14px;
padding: 14px 24px 4px;
font-size: 14px;
color: #1e293b;
}
.rrv__row-label {
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #5f6368;
}
.rrv__row-gl {
padding: 1px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 600;
}
.rrv__row-note {
font-size: 12px;
color: #5f6368;
}
.rrv__tables {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 8px 24px 16px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.rrv__formula {
color: #075985;
font-weight: 600;
}
.rrv__note {
padding: 0 24px 16px;
font-size: 13px;
color: #5f6368;
}
@@ -0,0 +1,220 @@
/**
* 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, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service';
import {
CibilSlab,
RateCell,
RateChartRow,
RateRegister
} from 'app/products/loan-interest-rate-register/models/rate-register.model';
/** How the register row was found for the product. */
export type RegisterMatch = 'gl' | 'type';
/** What the product takes from the register row, handed to the step that owns the rate fields. */
export interface RegisterRateSelection {
particular: string;
srNo: number;
glAccountId: number | null;
/** True when the row holds formulas such as "1% + FD": the rate is the deposit's own rate plus a spread. */
isFormula: boolean;
/** Lowest and highest plain rate in the row, the top one including the largest CIBIL adjustment. Null for a formula row. */
minRate: number | null;
maxRate: number | null;
/** The rate the product is created with: the lowest plain rate, or the lowest spread for a formula row. */
defaultRate: number | null;
/** Lowest and highest spread over the deposit rate; only for a formula row. */
minSpread: number | null;
maxSpread: number | null;
matchedBy: RegisterMatch;
registerName: string;
}
/**
* Product sub type id -> a word of the register particular that goes with it. This only covers the register's
* default rows. Any other product is found through its GL account, which every register row carries.
*/
const PARTICULAR_BY_SUB_TYPE: Record<string, string> = {
fd: 'against fdr',
rd: 'against rd',
gold: 'gold loan',
cc: 'cash credit',
overdraft: 'overdraft',
kcc: 'agriculture',
housing: 'housing loan',
personal: 'personal loan (unsecured)',
vehicle: 'car loan (new)',
business: 'business term loan',
term: 'business term loan',
gold_emi: 'gold loan'
};
/**
* Shows the interest of a product as the Loan Interest Rate Register defines it: the rate of the product's own
* register row for each amount slab, and the CIBIL adjustment. Nothing is chosen or typed here.
*
* The row is found from the product's GL account first (the register maps each row to a GL), then from its sub
* type. The row found is reported through `selectionChange`, or null when there is none, which blocks the product.
*/
@Component({
selector: 'mifosx-register-rate-view',
templateUrl: './register-rate-view.component.html',
styleUrls: [
'../advance-steps.scss',
'./register-rate-view.component.scss'
],
imports: [RouterLink]
})
export class RegisterRateViewComponent implements OnInit, OnChanges {
private registerService = inject(RateRegisterService);
/** Sub type id picked on the Details step, e.g. `fd`. */
@Input() productSubType = '';
/** The product's GL account id from the Details step. */
@Input() glAccountId: number | string | null = null;
@Output() selectionChange = new EventEmitter<RegisterRateSelection | null>();
state: 'loading' | 'ready' | 'none' = 'loading';
register?: RateRegister;
selectedSrNo: number | null = null;
matchedBy: RegisterMatch | null = null;
/** More than one register row fits the product and nothing tells them apart. */
ambiguous = false;
ngOnInit(): void {
this.registerService.getGlOptions().subscribe();
this.registerService.list().subscribe(() => {
this.register = this.registerService.getActive();
this.state = this.register && this.register.rows.length > 0 ? 'ready' : 'none';
this.autoSelect();
});
}
ngOnChanges(changes: SimpleChanges): void {
// The Details step can change after this one has opened, and a new type or GL means a new row.
if ((changes['productSubType'] || changes['glAccountId']) && this.state === 'ready') {
this.autoSelect();
}
}
get rows(): RateChartRow[] {
return this.register?.rows ?? [];
}
get selectedRow(): RateChartRow | undefined {
return this.rows.find((row) => row.srNo === this.selectedSrNo);
}
get cibilSlabs(): CibilSlab[] {
return this.register?.cibilSlabs ?? [];
}
glLabel(row: RateChartRow): string | null {
return this.registerService.glLabel(row.glAccountId);
}
/** The row's rate for every amount slab, ready to show. */
get slabRates(): { label: string; text: string; formula: boolean; na: boolean }[] {
const row = this.selectedRow;
if (!row || !this.register) {
return [];
}
return this.register.slabs.map((slab, index) => {
const cell: RateCell = row.rates[index] ?? null;
return {
label: slab.label,
text: cell === null ? 'N.A.' : typeof cell === 'string' ? cell : `${cell.toFixed(2)}%`,
formula: typeof cell === 'string',
na: cell === null
};
});
}
/** Why the product cannot go on: what the officer has to do in the register first. */
get blockReason(): string {
const hasGl = this.glAccountId !== null && this.glAccountId !== '';
if (!this.productSubType && !hasGl) {
return 'Choose the product type and the GL account on the Details step first.';
}
const gl = hasGl ? this.registerService.glLabel(Number(this.glAccountId)) : null;
if (this.ambiguous) {
return `More than one register row uses the GL account${gl ? ` (${gl})` : ''}. Give each row its own GL account in the register so this product can be matched to one of them.`;
}
const forGl = gl ? ` with its GL account (${gl})` : ' with its GL account';
return `Add this product${forGl} and its interest rates in the register and get the register approved. The product can be created only after that.`;
}
/** How the row was found, shown beside it. */
get matchNote(): string {
return this.matchedBy === 'gl'
? `Found through the product's GL account (${this.glLabel(this.selectedRow!) ?? 'GL'}).`
: 'Found through the product type.';
}
private autoSelect(): void {
const rows = this.rows;
const word = PARTICULAR_BY_SUB_TYPE[this.productSubType]?.toLowerCase();
const byType = word ? rows.filter((row) => row.particulars.toLowerCase().includes(word)) : [];
const gl = this.glAccountId === null || this.glAccountId === '' ? null : Number(this.glAccountId);
const byGl = gl === null ? [] : rows.filter((row) => row.glAccountId === gl);
let chosen: RateChartRow | undefined;
let by: RegisterMatch | null = null;
this.ambiguous = false;
if (byGl.length === 1) {
chosen = byGl[0];
by = 'gl';
} else if (byGl.length > 1) {
// Several rows share the GL: the product type tells them apart, or nothing does.
chosen = byGl.find((row) => byType.includes(row));
by = chosen ? 'type' : null;
this.ambiguous = !chosen;
} else if (byType.length === 1) {
chosen = byType[0];
by = 'type';
} else if (byType.length > 1) {
this.ambiguous = true;
}
this.selectedSrNo = chosen?.srNo ?? null;
this.matchedBy = by;
this.emit();
}
private emit(): void {
const row = this.selectedRow;
if (!row || !this.register) {
this.selectionChange.emit(null);
return;
}
const plain = row.rates.filter((cell): cell is number => typeof cell === 'number');
const spreads = row.rates
.filter((cell): cell is string => typeof cell === 'string')
.map((text) => /(\d+(?:\.\d+)?)\s*%/.exec(text)?.[1])
.filter((value): value is string => !!value)
.map(Number);
const topAdjustment = Math.max(0, ...this.cibilSlabs.map((slab) => Number(slab.adjustmentPct) || 0));
const isFormula = spreads.length > 0 && plain.length === 0;
const minRate = plain.length > 0 ? Math.min(...plain) : null;
this.selectionChange.emit({
particular: row.particulars,
srNo: row.srNo,
glAccountId: row.glAccountId ?? null,
isFormula,
minRate,
maxRate: plain.length > 0 ? Math.round((Math.max(...plain) + topAdjustment) * 100) / 100 : null,
defaultRate: isFormula ? Math.min(...spreads) : minRate,
minSpread: isFormula ? Math.min(...spreads) : null,
maxSpread: isFormula ? Math.max(...spreads) : null,
matchedBy: this.matchedBy ?? 'type',
registerName: this.register.name
});
}
}
@@ -0,0 +1,64 @@
/**
* 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 category ("product type") and sub type of a loan product. One list shared by the create-product wizard, the
* product list filters and the loan account product picker, so a type added here appears in all three.
*/
export interface ProductTypeGroup {
id: string;
label: string;
hint: string;
}
export interface ProductSubType {
id: string;
label: string;
/** The two-letter code the product's Short Name is pre-filled with when the sub type is picked. */
shortName: string;
}
export const PRODUCT_TYPE_GROUPS: ProductTypeGroup[] = [
{ id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' },
{ id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' }
];
export const PRODUCT_SUB_TYPES: Record<string, ProductSubType[]> = {
advances: [
{ id: 'fd', label: 'Loan against FD', shortName: 'FD' },
{ id: 'rd', label: 'Loan against RD', shortName: 'RD' },
{ id: 'dds', label: 'Loan against DDS', shortName: 'DS' },
{ id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' },
{ id: 'gold', label: 'Loan against Gold', shortName: 'GD' },
{ id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' },
{ id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' },
{ id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' },
{ id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' },
{ id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' }
],
loans: [
{ id: 'term', label: 'Term Loan', shortName: 'TL' },
{ id: 'personal', label: 'Personal Loan', shortName: 'PL' },
{ id: 'housing', label: 'Housing Loan', shortName: 'HL' },
{ id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' },
{ id: 'education', label: 'Education Loan', shortName: 'EL' },
{ id: 'business', label: 'Business / MSME Loan', shortName: 'BL' },
{ id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' }
]
};
/** Label of a category id, or a dash for a product that carries none (created before categories existed). */
export function productCategoryLabel(group: string | null | undefined): string {
return PRODUCT_TYPE_GROUPS.find((g) => g.id === group)?.label ?? '—';
}
/** Label of a sub type id within its category, or a dash when unknown. */
export function productTypeLabel(group: string | null | undefined, subGroup: string | null | undefined): string {
return (PRODUCT_SUB_TYPES[group ?? ''] ?? []).find((s) => s.id === subGroup)?.label ?? '—';
}
@@ -15,3 +15,49 @@
--pl-accent: #b45309; --pl-accent: #b45309;
--pl-accent-l: #fef3c7; --pl-accent-l: #fef3c7;
} }
/* Category / product type / active-only filters beside the search box. */
.pl-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.pl-filter {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: #64748b;
select {
height: 34px;
min-width: 160px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
font-size: 13px;
color: #1e293b;
}
&--check {
flex-direction: row;
align-items: center;
gap: 6px;
height: 34px;
font-size: 13px;
color: #1e293b;
}
}
.pl-tag--advances {
background: #e0f2fe;
color: #075985;
}
.pl-tag--loans {
background: #ede9fe;
color: #5b21b6;
}
@@ -42,6 +42,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types';
@Component({ @Component({
selector: 'mifosx-loan-products', selector: 'mifosx-loan-products',
@@ -82,6 +83,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
displayedColumns: string[] = [ displayedColumns: string[] = [
'name', 'name',
'shortName', 'shortName',
'category',
'productType',
'closeDate', 'closeDate',
'status', 'status',
'actions' 'actions'
@@ -90,6 +93,31 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
filteredCount = 0; filteredCount = 0;
/** Category and type filters, fed by the classification each product carries. */
readonly categories = PRODUCT_TYPE_GROUPS;
categoryFilter = '';
typeFilter = '';
activeOnly = false;
textFilter = '';
/** Types offered for the chosen category; every type when no category is chosen. */
get typeOptions() {
return this.categoryFilter
? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? [])
: Object.values(PRODUCT_SUB_TYPES).flat();
}
categoryOf(product: any): string {
return productCategoryLabel(product?.advanceExtensions?.productTypeGroup);
}
typeOf(product: any): string {
return productTypeLabel(
product?.advanceExtensions?.productTypeGroup,
product?.advanceExtensions?.productTypeSubGroup
);
}
get totalCount(): number { get totalCount(): number {
return this.loanProductsData?.length || 0; return this.loanProductsData?.length || 0;
} }
@@ -130,12 +158,52 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
this.dataSource = new MatTableDataSource(this.loanProductsData); this.dataSource = new MatTableDataSource(this.loanProductsData);
this.dataSource.paginator = this.paginator; this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product);
this.filteredCount = this.totalCount; this.filteredCount = this.totalCount;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
} }
applyFilter(filterValue: string) { applyFilter(filterValue: string) {
this.dataSource.filter = (filterValue || '').trim().toLowerCase(); this.textFilter = (filterValue || '').trim().toLowerCase();
this.refreshFilter();
}
onCategoryChange(): void {
// A type belongs to one category, so a type from another category cannot stay selected.
if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) {
this.typeFilter = '';
}
this.refreshFilter();
}
/** The filter value itself is unused (the predicate reads the fields above); changing it re-runs the predicate. */
refreshFilter(): void {
this.dataSource.filter = `${this.categoryFilter}|${this.typeFilter}|${this.activeOnly}|${this.textFilter}`;
}
private matchesFilters(product: any): boolean {
const ext = product?.advanceExtensions ?? {};
if (this.categoryFilter && ext.productTypeGroup !== this.categoryFilter) {
return false;
}
if (this.typeFilter && ext.productTypeSubGroup !== this.typeFilter) {
return false;
}
if (this.activeOnly && product?.status !== 'loanProduct.active') {
return false;
}
if (this.textFilter) {
const haystack = [
product?.name,
product?.shortName,
this.categoryOf(product),
this.typeOf(product)
]
.join(' ')
.toLowerCase();
return haystack.includes(this.textFilter);
}
return true;
} }
/** /**
@@ -50,10 +50,29 @@ export class LoanProducts {
delete loanProduct.advancedAccountingRules; delete loanProduct.advancedAccountingRules;
delete loanProduct.loanPeriod; delete loanProduct.loanPeriod;
// The Advances payload is assembled explicitly in the create component and already carries only // The Accounting step carries CBS-specific GL mappings that the backend does not accept yet. They are
// whitelisted params (core Fineract fields + satellite ADVANCE_PARAMETERS), so it is NOT stripped. // sent as '' when the officer leaves them blank, and the backend rejects the whole product for an
// For the Loans path, the Details/Security fields below are not backend params, so drop them. // unsupported parameter, so drop the ones that are empty. A mapping the officer actually selects is still
if (loanProduct.productTypeGroup !== 'advances') { // sent and gets a clear "not supported" answer rather than being silently ignored.
[
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId'
].forEach((key) => {
if (loanProduct[key] === '' || loanProduct[key] === null || loanProduct[key] === undefined) {
delete loanProduct[key];
}
});
// The Advances and Loans A/C (EMI) payloads are assembled explicitly in the create component and already
// carry only whitelisted params (core Fineract fields + satellite params), so they are NOT stripped.
// For any other product (the old Settings/Terms path), the Details/Security fields below are not backend
// params, so drop them.
if (loanProduct.productTypeGroup !== 'advances' && loanProduct.productTypeGroup !== 'loans') {
delete loanProduct.productTypeGroup; delete loanProduct.productTypeGroup;
delete loanProduct.productTypeSubGroup; delete loanProduct.productTypeSubGroup;
delete loanProduct.glAccount; delete loanProduct.glAccount;
@@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
@@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateBucketComponent implements OnInit { export class CreateBucketComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
dialog = inject(MatDialog); dialog = inject(MatDialog);
@@ -174,6 +176,15 @@ export class CreateBucketComponent implements OnInit {
}; };
this.productsService.createDelinquencyBucket(data).subscribe((response: any) => { this.productsService.createDelinquencyBucket(data).subscribe((response: any) => {
// Maker-checker: the request waits in the Checker Inbox, so there is no bucket to open yet.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency bucket request submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
this.router.navigate( this.router.navigate(
[ [
'../', '../',
@@ -7,6 +7,8 @@
--> -->
<div class="container"> <div class="container">
<mifosx-delinquency-pending-requests entityName="DELINQUENCY_BUCKET"></mifosx-delinquency-pending-requests>
<div class="table-header"> <div class="table-header">
<div class="table-title-group"> <div class="table-title-group">
<h2 class="table-title"> <h2 class="table-title">
@@ -24,6 +24,7 @@ import {
} from '@angular/material/table'; } from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FaIconComponent, FaIconComponent,
DelinquencyPendingRequestsComponent,
MatTable, MatTable,
MatSort, MatSort,
MatColumnDef, MatColumnDef,
@@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
@@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class EditBucketComponent implements OnInit { export class EditBucketComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
dialog = inject(MatDialog); dialog = inject(MatDialog);
@@ -191,7 +193,14 @@ export class EditBucketComponent implements OnInit {
ranges: ranges ranges: ranges
}; };
this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe(() => { this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe((response: any) => {
// Maker-checker: the change is applied only after a checker approves it.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency bucket change submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route }); this.router.navigate(['../'], { relativeTo: this.route });
}); });
} }
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -28,6 +29,7 @@ export class ViewBucketComponent {
private router = inject(Router); private router = inject(Router);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private alertService = inject(AlertService);
/** Delinquency Bucket Data. */ /** Delinquency Bucket Data. */
delinquencyBucketData: any; delinquencyBucketData: any;
@@ -47,7 +49,14 @@ export class ViewBucketComponent {
}); });
dialogRef.afterClosed().subscribe((response: any) => { dialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) { if (response.delete) {
this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe(() => { this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe((result: any) => {
// Maker-checker: the bucket is deleted only after a checker approves.
if (result?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency bucket delete request submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route }); this.router.navigate(['../'], { relativeTo: this.route });
}); });
} }
@@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CreateRangeComponent implements OnInit { export class CreateRangeComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
@@ -71,6 +73,15 @@ export class CreateRangeComponent implements OnInit {
locale locale
}; };
this.productsService.createDelinquencyRange(data).subscribe((response: any) => { this.productsService.createDelinquencyRange(data).subscribe((response: any) => {
// Maker-checker: the request waits in the Checker Inbox, so there is no range to open yet.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency range request submitted for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
return;
}
this.router.navigate( this.router.navigate(
[ [
'../', '../',
@@ -7,6 +7,8 @@
--> -->
<div class="container"> <div class="container">
<mifosx-delinquency-pending-requests entityName="DELINQUENCY_RANGE"></mifosx-delinquency-pending-requests>
<div class="table-header"> <div class="table-header">
<div class="table-title-group"> <div class="table-title-group">
<h2 class="table-title"> <h2 class="table-title">
@@ -24,6 +24,7 @@ import {
} from '@angular/material/table'; } from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FaIconComponent, FaIconComponent,
DelinquencyPendingRequestsComponent,
MatTable, MatTable,
MatSort, MatSort,
MatColumnDef, MatColumnDef,
@@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class EditRangeComponent implements OnInit { export class EditRangeComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
@@ -78,6 +80,15 @@ export class EditRangeComponent implements OnInit {
locale locale
}; };
this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => { this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => {
// Maker-checker: the change is applied only after a checker approves it.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency range change submitted for approval.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
return;
}
this.router.navigate( this.router.navigate(
[ [
'../../', '../../',
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -28,6 +29,7 @@ export class ViewRangeComponent {
private router = inject(Router); private router = inject(Router);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private productsService = inject(ProductsService); private productsService = inject(ProductsService);
private alertService = inject(AlertService);
/** Delinquency Range Data. */ /** Delinquency Range Data. */
delinquencyRangeData: any; delinquencyRangeData: any;
@@ -44,7 +46,14 @@ export class ViewRangeComponent {
}); });
dialogRef.afterClosed().subscribe((response: any) => { dialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) { if (response.delete) {
this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe(() => { this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe((result: any) => {
// Maker-checker: the range is deleted only after a checker approves.
if (result?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Delinquency range delete request submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route }); this.router.navigate(['../'], { relativeTo: this.route });
}); });
} }
@@ -0,0 +1,55 @@
<!--
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/.
-->
@if (rows.length > 0) {
<div class="dpr">
<div class="dpr__head">
<i class="fas fa-hourglass-half"></i>
<strong>{{ rows.length }} request{{ rows.length > 1 ? 's' : '' }} awaiting approval</strong>
<span class="dpr__sub">These changes take effect only after a checker approves them.</span>
<a class="dpr__link" routerLink="/checker-inbox-and-tasks/checker-inbox">Open Approvals →</a>
</div>
<table class="dpr__table">
<thead>
<tr>
<th>Action</th>
<th>Record</th>
<th>What changes</th>
<th>Requested by</th>
<th>Requested on</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr>
<td>
<span class="dpr__chip">{{ row.action }}</span>
</td>
<td>{{ row.subject }}</td>
<td>
@if (row.changes.length > 0) {
@for (change of row.changes; track change.label) {
<div class="dpr__change">
<span class="dpr__field">{{ change.label }}</span>
<span class="dpr__old">{{ change.oldValue }}</span>
<span class="dpr__arrow">→</span>
<span class="dpr__new">{{ change.newValue }}</span>
</div>
}
} @else {
{{ row.detail }}
}
</td>
<td>{{ row.maker }}</td>
<td>{{ row.madeOn | date: 'dd MMM yyyy, hh:mm a' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@@ -0,0 +1,108 @@
/**
* 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/.
*/
.dpr {
margin-bottom: 16px;
border: 1px solid #fcd34d;
border-radius: 10px;
background: #fffbeb;
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
font-size: 13px;
color: #78350f;
i {
color: #d97706;
}
}
&__sub {
color: #92400e;
}
&__link {
margin-left: auto;
font-weight: 600;
color: #92400e;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
padding: 8px 16px;
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #92400e;
border-top: 1px solid #fde68a;
}
td {
padding: 8px 16px;
border-top: 1px solid #fde68a;
color: #1e293b;
}
}
&__change {
display: flex;
align-items: baseline;
gap: 8px;
padding: 2px 0;
}
&__field {
min-width: 90px;
font-size: 12px;
color: #92400e;
}
&__old {
padding: 0 6px;
border-radius: 4px;
background: #fee2e2;
color: #991b1b;
text-decoration: line-through;
}
&__arrow {
color: #92400e;
}
&__new {
padding: 0 6px;
border-radius: 4px;
background: #dcfce7;
color: #166534;
font-weight: 600;
}
&__chip {
display: inline-block;
padding: 1px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 600;
}
}
@@ -0,0 +1,210 @@
/**
* 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, Input, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
/** One field that a pending request would change: what it is now, and what it would become. */
interface PendingChange {
label: string;
oldValue: string;
newValue: string;
}
interface PendingRow {
id: number;
action: string;
/** What the request is about, e.g. the range or bucket name. */
subject: string;
/** UPDATE: the fields that move, old -> new. */
changes: PendingChange[];
/** CREATE / DELETE: a plain description of what is being added or removed. */
detail: string;
maker: string;
madeOn: Date | null;
}
/**
* Lists the Delinquency Range / Bucket requests that a maker has submitted and a checker has not yet
* approved. Without this the list pages look unchanged after a submit, so the maker cannot tell the
* request exists, what it would change, or where to approve it.
*/
@Component({
selector: 'mifosx-delinquency-pending-requests',
templateUrl: './delinquency-pending-requests.component.html',
styleUrls: ['./delinquency-pending-requests.component.scss'],
imports: [
DatePipe,
RouterLink
]
})
export class DelinquencyPendingRequestsComponent implements OnInit {
private productsService = inject(ProductsService);
/** DELINQUENCY_RANGE or DELINQUENCY_BUCKET. */
@Input({ required: true }) entityName: string;
rows: PendingRow[] = [];
ngOnInit(): void {
this.productsService
.getPendingMakerCheckerByEntity(this.entityName)
.pipe(
catchError(() => of([])),
switchMap((entries: any[]) => {
if (!entries || entries.length === 0) {
return of([] as PendingRow[]);
}
// The list call has no payload and no current values. The payload comes from the audit entry,
// and the current range or bucket from the normal read calls, so an UPDATE can show old -> new.
return forkJoin(entries.map((entry) => this.buildRow(entry)));
})
)
.subscribe((rows) => (this.rows = rows));
}
private buildRow(entry: any): Observable<PendingRow> {
const isBucket = this.entityName === 'DELINQUENCY_BUCKET';
const action = (entry.actionName ?? '').toUpperCase();
const hasTarget = action !== 'CREATE' && entry.resourceId !== undefined && entry.resourceId !== null;
const audit$ = this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe(catchError(() => of(null)));
const current$ = hasTarget
? (isBucket
? this.productsService.getDelinquencyBucket(entry.resourceId)
: this.productsService.getDelinquencyRange(entry.resourceId)
).pipe(catchError(() => of(null)))
: of(null);
// Bucket ranges are stored and submitted as ids; the names come from the range list.
const ranges$ = isBucket ? this.productsService.getDelinquencyRanges().pipe(catchError(() => of([]))) : of([]);
return forkJoin({ audit: audit$, current: current$, ranges: ranges$ }).pipe(
map(({ audit, current, ranges }) => {
let payload: any = null;
try {
payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : null;
} catch {
payload = null;
}
const rangesById = new Map<number, any>(
(ranges ?? []).map((range: any) => [
Number(range.id),
range
])
);
const row: PendingRow = {
id: entry.id,
action,
subject: '',
changes: [],
detail: '',
maker: entry.maker,
madeOn: entry.madeOnDate ? new Date(Number(entry.madeOnDate) * 1000) : null
};
if (isBucket) {
this.fillBucket(row, action, payload, current, rangesById);
} else {
this.fillRange(row, action, payload, current);
}
if (!row.subject) {
row.subject = entry.resourceId ? `#${entry.resourceId}` : '—';
}
return row;
})
);
}
private fillRange(row: PendingRow, action: string, payload: any, current: any): void {
const describe = (range: any) =>
`${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)`;
if (action === 'CREATE' && payload) {
row.subject = payload.classification ?? '';
row.detail = `New range: ${describe(payload)}`;
} else if (action === 'DELETE' && current) {
row.subject = current.classification ?? '';
row.detail = `Range to be deleted: ${describe(current)}`;
} else if (action === 'UPDATE' && current) {
row.subject = current.classification ?? '';
const fields: [string, string][] = [
[
'classification',
'Classification'
],
[
'minimumAgeDays',
'Days from'
],
[
'maximumAgeDays',
'Days till'
]
];
row.changes = this.diff(fields, payload, current);
if (row.changes.length === 0) {
row.detail = 'No field value changes';
}
}
}
private fillBucket(row: PendingRow, action: string, payload: any, current: any, rangesById: Map<number, any>): void {
const names = (ids: any[]) =>
(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 #${range.id}`)
.join(', ');
if (action === 'CREATE' && payload) {
row.subject = payload.name ?? '';
row.detail = `New bucket with ranges: ${names(payload.ranges) || '—'}`;
} else if (action === 'DELETE' && current) {
row.subject = current.name ?? '';
row.detail = `Bucket to be deleted (ranges: ${names(current.ranges) || '—'})`;
} else if (action === 'UPDATE' && current && payload) {
row.subject = current.name ?? '';
const before = { name: current.name, ranges: names(current.ranges) };
const after = { name: payload.name ?? current.name, ranges: names(payload.ranges ?? current.ranges) };
row.changes = this.diff(
[
[
'name',
'Name'
],
[
'ranges',
'Ranges'
]
],
after,
before
);
if (row.changes.length === 0) {
row.detail = 'No field value changes';
}
}
}
/** The fields whose submitted value differs from today's. */
private diff(fields: [string, string][], submitted: any, current: any): PendingChange[] {
return fields
.filter(([key]) => submitted && submitted[key] !== undefined && String(submitted[key]) !== String(current?.[key]))
.map(
([
key,
label
]) => ({
label,
oldValue: String(current?.[key] ?? '—'),
newValue: String(submitted[key])
})
);
}
}
+28
View File
@@ -860,6 +860,34 @@ export class ProductsService {
return this.http.get(`/loanproducts/baseinterestrates/${id}`, this.silentCtx()); return this.http.get(`/loanproducts/baseinterestrates/${id}`, this.silentCtx());
} }
/** Submitted Loan Rate Register versions still awaiting a checker (maker-checker entries with their payload). */
getPendingLoanRateRegisters(): Observable<any> {
const params = new HttpParams().set('entityName', 'LOANBASEINTERESTRATE');
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
}
/** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */
getPendingMakerCheckerByEntity(entityName: string): Observable<any> {
const params = new HttpParams().set('entityName', entityName);
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
}
/** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
getLoanRateRegisterAuditDetail(id: number): Observable<any> {
return this.http.get(`/audits/${id}`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
}
/** GL accounts a particular can be mapped to: asset (type 1) posting (detail) accounts that are not disabled. */
getLoanRegisterGlAccounts(): Observable<any> {
const params = new HttpParams().set('type', '1').set('usage', '1').set('disabled', 'false');
return this.http.get('/glaccounts', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
}
/** Returns the master list of particulars (loan types) that make up the Loan Rate Register chart rows. */
getLoanRateRegisterProducts(): Observable<any> {
return this.http.get('/loanproducts/baseinterestrates/products', this.silentCtx());
}
/** Submits a new Loan Rate Register version. If maker-checker is enabled, the response's /** Submits a new Loan Rate Register version. If maker-checker is enabled, the response's
* `rollbackTransaction` flag is true and the row sits in the Approvals tab until a checker approves. */ * `rollbackTransaction` flag is true and the row sits in the Approvals tab until a checker approves. */
saveLoanBaseInterestRate(payload: any): Observable<any> { saveLoanBaseInterestRate(payload: any): Observable<any> {
@@ -340,7 +340,6 @@ $red: #c5221f;
} }
// ── Day by day ── // ── Day by day ──
//
// Outside the form's own sheet, because it is not part of what gets filed: the form reports two // Outside the form's own sheet, because it is not part of what gets filed: the form reports two
// closes, and this is the working behind them. // closes, and this is the working behind them.
.f1s-daily { .f1s-daily {
@@ -531,8 +531,12 @@ export abstract class ReserveReturnBase {
// A half-typed year parses as a real date — "30-09-0026" is year 26 — and then fails the // A half-typed year parses as a real date — "30-09-0026" is year 26 — and then fails the
// ordering check below with a message about column order, which is not what went wrong. // ordering check below with a message about column order, which is not what went wrong.
// Caught first so the filer is told about the year rather than sent looking at the dates. // Caught first so the filer is told about the year rather than sent looking at the dates.
const halfTyped = [this.statutoryFirstColumn, this.statutorySecondColumn, this.statutoryFirstBase, this.statutorySecondBase] const halfTyped = [
.filter((d) => d && Number(d.slice(0, 4)) < 1900); this.statutoryFirstColumn,
this.statutorySecondColumn,
this.statutoryFirstBase,
this.statutorySecondBase
].filter((d) => d && Number(d.slice(0, 4)) < 1900);
if (halfTyped.length) { if (halfTyped.length) {
this.statutoryError = `The year in ${halfTyped[0]} looks incomplete — enter all four digits.`; this.statutoryError = `The year in ${halfTyped[0]} looks incomplete — enter all four digits.`;
return; return;
@@ -1362,7 +1366,9 @@ export abstract class ReserveReturnBase {
private addCimsGeneralInformation(wb: ExcelJS.Workbook): void { private addCimsGeneralInformation(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('General Information'); const sheet = wb.addWorksheet('General Information');
sheet.columns = [{ width: 40 }, { width: 52 }]; sheet.columns = [
{ width: 40 },
{ width: 52 }];
addBanner(sheet, 'General Information', 'B'); addBanner(sheet, 'General Information', 'B');
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
@@ -1401,7 +1407,10 @@ export abstract class ReserveReturnBase {
/** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */ /** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */
private addCimsPartA(wb: ExcelJS.Workbook): void { private addCimsPartA(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part A'); const sheet = wb.addWorksheet('Part A');
sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; sheet.columns = [
{ width: 72 },
...this.cimsDates.map(() => ({ width: 22 }))
];
addBanner(sheet, 'Part A', this.cimsLastColumn()); addBanner(sheet, 'Part A', this.cimsLastColumn());
sheet.addRow([]); sheet.addRow([]);
this.addCimsEndDates(sheet, 'End Date'); this.addCimsEndDates(sheet, 'End Date');
@@ -1410,7 +1419,10 @@ export abstract class ReserveReturnBase {
for (const line of this.statutory?.partA?.lines ?? []) { for (const line of this.statutory?.partA?.lines ?? []) {
// The form's own indentation carries its numbering — (a), then (i) — so a flattened sheet // The form's own indentation carries its numbering — (a), then (i) — so a flattened sheet
// would lose which line sits under which head. // would lose which line sits under which head.
const row = sheet.addRow([`${' '.repeat(line.indent ?? 0)}${line.label}`, ...(line.values ?? [])]); const row = sheet.addRow([
`${' '.repeat(line.indent ?? 0)}${line.label}`,
...(line.values ?? [])
]);
row.getCell(1).alignment = { wrapText: true }; row.getCell(1).alignment = { wrapText: true };
if (line.heading || line.total) { if (line.heading || line.total) {
row.font = { bold: true }; row.font = { bold: true };
@@ -1429,7 +1441,11 @@ export abstract class ReserveReturnBase {
*/ */
private addCimsOthersSheet(wb: ExcelJS.Workbook): void { private addCimsOthersSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part A.II.c'); const sheet = wb.addWorksheet('Part A.II.c');
sheet.columns = [{ width: 10 }, { width: 50 }, ...this.cimsDates.map(() => ({ width: 22 }))]; sheet.columns = [
{ width: 10 },
{ width: 50 },
...this.cimsDates.map(() => ({ width: 22 }))
];
addBanner(sheet, 'Part A.II.c', this.columnLetter(this.cimsDates.length + 2)); addBanner(sheet, 'Part A.II.c', this.columnLetter(this.cimsDates.length + 2));
sheet.addRow([]); sheet.addRow([]);
@@ -1437,7 +1453,11 @@ export abstract class ReserveReturnBase {
const last = this.cimsDates.length + 2; const last = this.cimsDates.length + 2;
// "As at the close of Business on" heads BOTH dated columns as one caption, as the template has // "As at the close of Business on" heads BOTH dated columns as one caption, as the template has
// it — written once and merged across them, not repeated over each. // it — written once and merged across them, not repeated over each.
const head = sheet.addRow(['Sr.No.', 'Particulars', 'As at the close of Business on']); const head = sheet.addRow([
'Sr.No.',
'Particulars',
'As at the close of Business on'
]);
head.font = { bold: true }; head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' }; head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
@@ -1448,7 +1468,11 @@ export abstract class ReserveReturnBase {
head.getCell(c).fill = solidFill(XL.headerGrey); head.getCell(c).fill = solidFill(XL.headerGrey);
} }
const dates = sheet.addRow(['', '', ...this.cimsDates]); const dates = sheet.addRow([
'',
'',
...this.cimsDates
]);
dates.font = { bold: true }; dates.font = { bold: true };
dates.alignment = { horizontal: 'center' }; dates.alignment = { horizontal: 'center' };
for (let c = 3; c <= last; c++) { for (let c = 3; c <= last; c++) {
@@ -1457,12 +1481,20 @@ export abstract class ReserveReturnBase {
// Sr.No. 1, hatched, as the template carries it: the break-up is written in by hand, so the row // Sr.No. 1, hatched, as the template carries it: the break-up is written in by hand, so the row
// has to be there to be written in. An empty sheet with only a Total says the bank reported // has to be there to be written in. An empty sheet with only a Total says the bank reported
// nothing; a hatched row says nobody has filled it in yet. // nothing; a hatched row says nobody has filled it in yet.
const entry = sheet.addRow([1, '', ...this.cimsDates.map((): null => null)]); const entry = sheet.addRow([
1,
'',
...this.cimsDates.map((): null => null)
]);
for (let c = 2; c <= last; c++) { for (let c = 2; c <= last; c++) {
entry.getCell(c).fill = hatchFill(); entry.getCell(c).fill = hatchFill();
} }
const total = sheet.addRow(['', 'Total', ...this.cimsDates.map(() => 0)]); const total = sheet.addRow([
'',
'Total',
...this.cimsDates.map(() => 0)
]);
total.font = { bold: true }; total.font = { bold: true };
total.getCell(1).fill = solidFill(XL.headerGrey); total.getCell(1).fill = solidFill(XL.headerGrey);
total.getCell(2).alignment = { horizontal: 'center' }; total.getCell(2).alignment = { horizontal: 'center' };
@@ -1477,7 +1509,10 @@ export abstract class ReserveReturnBase {
/** Parts B and C on one sheet, as the template has them. */ /** Parts B and C on one sheet, as the template has them. */
private addCimsPartBandC(wb: ExcelJS.Workbook): void { private addCimsPartBandC(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part B and C'); const sheet = wb.addWorksheet('Part B and C');
sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; sheet.columns = [
{ width: 72 },
...this.cimsDates.map(() => ({ width: 22 }))
];
addBanner(sheet, 'Part B and C', this.cimsLastColumn()); addBanner(sheet, 'Part B and C', this.cimsLastColumn());
sheet.addRow([]); sheet.addRow([]);
this.addCimsEndDates(sheet, 'End Date'); this.addCimsEndDates(sheet, 'End Date');
@@ -1524,7 +1559,10 @@ export abstract class ReserveReturnBase {
*/ */
private addCimsPartD(wb: ExcelJS.Workbook): void { private addCimsPartD(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Part D'); const sheet = wb.addWorksheet('Part D');
sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; sheet.columns = [
{ width: 72 },
...this.cimsDates.map(() => ({ width: 22 }))
];
addBanner(sheet, 'Part D', this.cimsLastColumn()); addBanner(sheet, 'Part D', this.cimsLastColumn());
sheet.addRow([]); sheet.addRow([]);
this.addCimsEndDates(sheet, 'End Date'); this.addCimsEndDates(sheet, 'End Date');
@@ -1568,11 +1606,22 @@ export abstract class ReserveReturnBase {
const perDate = 4; const perDate = 4;
const dates = this.cimsDates; const dates = this.cimsDates;
const sheet = wb.addWorksheet(name); const sheet = wb.addWorksheet(name);
sheet.columns = [{ width: 38 }, ...dates.flatMap(() => Array(perDate).fill({ width: 19 }))]; sheet.columns = [
{ width: 38 },
...dates.flatMap(() => Array(perDate).fill({ width: 19 }))
];
addBanner(sheet, name, this.columnLetter(dates.length * perDate + 1)); addBanner(sheet, name, this.columnLetter(dates.length * perDate + 1));
sheet.addRow([]); sheet.addRow([]);
const dateRow = sheet.addRow(['', ...dates.flatMap((d) => [d, '', '', ''])]); const dateRow = sheet.addRow([
'',
...dates.flatMap((d) => [
d,
'',
'',
''
])
]);
dateRow.font = { bold: true }; dateRow.font = { bold: true };
dates.forEach((_, i) => { dates.forEach((_, i) => {
const from = 2 + i * perDate; const from = 2 + i * perDate;
@@ -1584,12 +1633,25 @@ export abstract class ReserveReturnBase {
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const head = sheet.addRow([ const head = sheet.addRow([
'', '',
...dates.flatMap(() => ['Face Value', 'Book Value', 'Depreciation', 'Net Value for SLR purpose (2-3)']) ...dates.flatMap(() => [
'Face Value',
'Book Value',
'Depreciation',
'Net Value for SLR purpose (2-3)'
])
]); ]);
head.font = { bold: true }; head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' }; head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const numbers = sheet.addRow(['', ...dates.flatMap(() => [1, 2, 3, 4])]); const numbers = sheet.addRow([
'',
...dates.flatMap(() => [
1,
2,
3,
4
])
]);
numbers.font = { bold: true }; numbers.font = { bold: true };
numbers.alignment = { horizontal: 'center' }; numbers.alignment = { horizontal: 'center' };
@@ -1600,7 +1662,7 @@ export abstract class ReserveReturnBase {
const movement = (label: string, carries: boolean, closing = false) => { const movement = (label: string, carries: boolean, closing = false) => {
const cells: (string | number)[] = [label]; const cells: (string | number)[] = [label];
for (let i = 0; i < dates.length; i++) { for (let i = 0; i < dates.length; i++) {
const value = carries ? held[i] ?? 0 : 0; const value = carries ? (held[i] ?? 0) : 0;
cells.push(value, value, 0, value); cells.push(value, value, 0, value);
} }
const row = sheet.addRow(cells); const row = sheet.addRow(cells);
@@ -1654,8 +1716,7 @@ export abstract class ReserveReturnBase {
{ width: 21 }, { width: 21 },
{ width: 21 }, { width: 21 },
{ width: 19 }, { width: 19 },
{ width: 26 } { width: 26 }];
];
addBanner( addBanner(
sheet, sheet,
isCrr isCrr
@@ -1671,7 +1732,12 @@ export abstract class ReserveReturnBase {
const title = isCrr const title = isCrr
? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]' ? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]'
: 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]'; : 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]';
const band = sheet.addRow(['', '', '', title]); const band = sheet.addRow([
'',
'',
'',
title
]);
band.font = { bold: true }; band.font = { bold: true };
band.alignment = { wrapText: true, horizontal: 'center' }; band.alignment = { wrapText: true, horizontal: 'center' };
sheet.mergeCells(band.number, 4, band.number, 9); sheet.mergeCells(band.number, 4, band.number, 9);
@@ -1694,7 +1760,11 @@ export abstract class ReserveReturnBase {
head.alignment = { wrapText: true, horizontal: 'center' }; head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
// The first three columns are one heading spanning both rows, as the template has them. // The first three columns are one heading spanning both rows, as the template has them.
for (const c of [1, 2, 3]) { for (const c of [
1,
2,
3
]) {
sheet.mergeCells(band.number, c, head.number, c); sheet.mergeCells(band.number, c, head.number, c);
} }
@@ -1750,20 +1820,39 @@ export abstract class ReserveReturnBase {
private addCimsSignatory(wb: ExcelJS.Workbook): void { private addCimsSignatory(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Signatory Information'); const sheet = wb.addWorksheet('Signatory Information');
sheet.columns = [{ width: 26 }, { width: 36 }, { width: 36 }]; sheet.columns = [
{ width: 26 },
{ width: 36 },
{ width: 36 }];
addBanner(sheet, 'Signatory Information', 'C'); addBanner(sheet, 'Signatory Information', 'C');
sheet.addRow([]); sheet.addRow([]);
const start = sheet.rowCount + 1; const start = sheet.rowCount + 1;
const head = sheet.addRow(['', 'Authorised Reporting Official', 'Countersigned By']); const head = sheet.addRow([
'',
'Authorised Reporting Official',
'Countersigned By'
]);
head.font = { bold: true }; head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
// Left to be signed, and hatched the way the template hatches it so the blank reads as "fill // Left to be signed, and hatched the way the template hatches it so the blank reads as "fill
// this in" rather than as a field the return had nothing to say about. Who signs a filed return, // this in" rather than as a field the return had nothing to say about. Who signs a filed return,
// and on what date, is settled when it is filed — a name pre-filled here would put a signature // and on what date, is settled when it is filed — a name pre-filled here would put a signature
// on a form nobody had agreed to sign. // on a form nobody had agreed to sign.
for (const field of ['Name', 'Designation', 'e-mail ID', 'Tel. No. (O)', 'Tel. No. (R)', 'Place', 'Date']) { for (const field of [
const row = sheet.addRow([field, '', '']); 'Name',
'Designation',
'e-mail ID',
'Tel. No. (O)',
'Tel. No. (R)',
'Place',
'Date'
]) {
const row = sheet.addRow([
field,
'',
''
]);
row.getCell(1).font = { bold: true }; row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell); row.getCell(1).fill = solidFill(XL.keyCell);
row.getCell(2).fill = hatchFill(); row.getCell(2).fill = hatchFill();
@@ -1779,7 +1868,10 @@ export abstract class ReserveReturnBase {
* thing not shaded like the figures below it. * thing not shaded like the figures below it.
*/ */
private addCimsEndDates(sheet: ExcelJS.Worksheet, label: string): void { private addCimsEndDates(sheet: ExcelJS.Worksheet, label: string): void {
const row = sheet.addRow([label, ...this.cimsDates]); const row = sheet.addRow([
label,
...this.cimsDates
]);
row.font = { bold: true }; row.font = { bold: true };
row.getCell(1).alignment = { horizontal: 'right' }; row.getCell(1).alignment = { horizontal: 'right' };
for (let c = 2; c <= this.cimsDates.length + 1; c++) { for (let c = 2; c <= this.cimsDates.length + 1; c++) {
@@ -1826,7 +1918,10 @@ export abstract class ReserveReturnBase {
* that part at all. * that part at all.
*/ */
private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void { private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void {
const row = sheet.addRow([label, ...(values ?? this.cimsDates.map((): null => null))]); const row = sheet.addRow([
label,
...(values ?? this.cimsDates.map((): null => null))
]);
row.getCell(1).alignment = { wrapText: true }; row.getCell(1).alignment = { wrapText: true };
if (bold) { if (bold) {
row.font = { bold: true }; row.font = { bold: true };
@@ -1934,8 +2029,7 @@ export abstract class ReserveReturnBase {
{ width: 20 }, { width: 20 },
{ width: 18 }, { width: 18 },
{ width: 18 }, { width: 18 },
{ width: 20 } { width: 20 }];
];
addBanner(sheet, `Day by day — ${daily.from} to ${daily.to}`, 'J'); addBanner(sheet, `Day by day — ${daily.from} to ${daily.to}`, 'J');
// A day short is a breach. It goes above the table, where it is read before the figures rather // A day short is a breach. It goes above the table, where it is read before the figures rather
@@ -1979,7 +2073,16 @@ export abstract class ReserveReturnBase {
day.slrSurplus day.slrSurplus
]); ]);
// Column 3 is a date, so the figures run 2, then 4 onward. // Column 3 is a date, so the figures run 2, then 4 onward.
for (const c of [2, 4, 5, 6, 7, 8, 9, 10]) { for (const c of [
2,
4,
5,
6,
7,
8,
9,
10
]) {
row.getCell(c).numFmt = '#,##0.00'; row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' }; row.getCell(c).alignment = { horizontal: 'right' };
} }
@@ -592,8 +592,8 @@
@if (statutoryFirstColumn && statutorySecondColumn) { @if (statutoryFirstColumn && statutorySecondColumn) {
<p class="f1s-prompt__hint"> <p class="f1s-prompt__hint">
Made up to <strong>{{ statutorySecondColumn }}</strong Made up to <strong>{{ statutorySecondColumn }}</strong
>. The base dates above are the matching close one month earlier — change either >. The base dates above are the matching close one month earlier — change either one if the bank files on
one if the bank files on a different base. a different base.
</p> </p>
} @else { } @else {
<p class="f1s-prompt__hint">Enter both fortnight closing dates — the base dates fill in from them.</p> <p class="f1s-prompt__hint">Enter both fortnight closing dates — the base dates fill in from them.</p>
@@ -715,7 +715,7 @@
<td class="f1s-table__num">{{ amount(v) }}</td> <td class="f1s-table__num">{{ amount(v) }}</td>
} }
</tr> </tr>
<!-- <!--
The base dates used to print here as a row of their own, under IV(b). It is gone The base dates used to print here as a row of their own, under IV(b). It is gone
for two reasons: the filed form has no such line, so it put a row on the return for two reasons: the filed form has no such line, so it put a row on the return
that RBI's own format does not carry; and it described the base as "the matching that RBI's own format does not carry; and it described the base as "the matching
@@ -907,11 +907,13 @@
} }
/* Where the rating should land, read straight off the Current Risk column beside it. */ /* Where the rating should land, read straight off the Current Risk column beside it. */
/* stylelint-disable-next-line no-duplicate-selectors */
.rr-move-cell { .rr-move-cell {
min-width: 150px; min-width: 150px;
max-width: 190px; max-width: 190px;
} }
/* stylelint-disable-next-line no-duplicate-selectors */
.rr-move { .rr-move {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -922,12 +924,14 @@
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
/* stylelint-disable-next-line no-duplicate-selectors */
&--up { &--up {
background: var(--r-red-l); background: var(--r-red-l);
color: var(--r-red); color: var(--r-red);
} }
/* Coming down is a relief, not an alarm — same green the verdict uses. */ /* Coming down is a relief, not an alarm — same green the verdict uses. */
/* stylelint-disable-next-line no-duplicate-selectors */
&--down { &--down {
background: var(--r-green-l); background: var(--r-green-l);
color: var(--r-green); color: var(--r-green);
@@ -343,10 +343,13 @@ export class CheckerInboxComponent implements OnInit {
// resourceId is absent (CREATE rows: entity does not exist yet until approved), // 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 // or entityName is unmapped — fall back to the detail dialog so the checker
// can still view the command payload and action the entry. // 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 this.dialog
.open(ViewCheckerInboxComponent, { .open(ViewCheckerInboxComponent, {
data: { checkerId: row.id }, data: { checkerId: row.id },
autoFocus: false autoFocus: false,
...(wide ? { width: '1200px', maxWidth: '95vw' } : {})
}) })
.afterClosed() .afterClosed()
.subscribe((result: any) => { .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), FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true), RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(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 ---------------------------------------------------------------- // ---- Accounts ----------------------------------------------------------------
SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id), SAVINGSACCOUNT: (id, s) => s.savings.getSavingsAccountData(id),
@@ -199,6 +202,12 @@ const DELETE_SUMMARY_KEYS: Record<string, string[]> = {
'email', 'email',
'officeName' 'officeName'
], ],
DELINQUENCY_RANGE: [
'classification',
'minimumAgeDays',
'maximumAgeDays'
],
DELINQUENCY_BUCKET: ['name'],
GLACCOUNT: [ GLACCOUNT: [
'name', 'name',
'glCode', 'glCode',
@@ -6,7 +6,7 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. 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. --> <!-- The heading lives here in both modes, so a dialog does not end up showing it twice. -->
<div class="vci-head"> <div class="vci-head">
<span class="vci-head__title">{{ headingTitle }}</span> <span class="vci-head__title">{{ headingTitle }}</span>
@@ -114,10 +114,30 @@
</div> </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. --> <!-- UPDATE: only the fields this command actually moves, as OLD -> NEW. -->
@if (showChanges) { @if (showChanges) {
<div class="vci-section"> <div class="vci-section">
<div class="vci-section__title">Changes Requested ({{ changes.length }})</div> <div class="vci-section__title">Changes Requested ({{ changes.length }})</div>
@if (changesBaseline) {
<p class="vci-nochange">{{ changesBaseline }}</p>
}
@if (changes.length === 0) { @if (changes.length === 0) {
<p class="vci-nochange">This request does not change any value.</p> <p class="vci-nochange">This request does not change any value.</p>
} @else { } @else {
@@ -143,7 +163,7 @@
} }
<!-- CREATE: submitted data grouped into readable sections. --> <!-- 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) { @for (section of createSections; track section.title) {
<div class="vci-section"> <div class="vci-section">
<div class="vci-section__title">{{ section.title }}</div> <div class="vci-section__title">{{ section.title }}</div>
@@ -157,7 +177,7 @@
} }
<!-- DELETE summary, role-permission changes, and the fallback payload all render here. --> <!-- 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">
<div class="vci-section__title"> <div class="vci-section__title">
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }} {{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
@@ -32,6 +32,11 @@ $bg-page: #f8fafc;
border-radius: 10px; 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) { &:not(&--dialog) {
max-width: 900px; max-width: 900px;
margin: 20px auto; margin: 20px auto;
@@ -10,6 +10,7 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { forkJoin } from 'rxjs';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
/** Custom Services */ /** Custom Services */
@@ -31,6 +32,7 @@ import {
groupCreateFields, groupCreateFields,
humanizeKey humanizeKey
} from '../command-diff.util'; } from '../command-diff.util';
import { diffLoanRateRegister, isLoanBaseInterestRateCreate } from '../loan-rate-register-diff.util';
import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch'; import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch';
/** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */ /** 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 { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { OrganizationService } from 'app/organization/organization.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 * 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, ...STANDALONE_SHARED_IMPORTS,
FaIconComponent, FaIconComponent,
NgTemplateOutlet, NgTemplateOutlet,
DateFormatPipe DateFormatPipe,
RateRegisterDiffComponent
] ]
}) })
export class ViewCheckerInboxComponent { export class ViewCheckerInboxComponent {
@@ -150,6 +169,7 @@ export class ViewCheckerInboxComponent {
private fixedDepositsService = inject(FixedDepositsService); private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService); private recurringDepositsService = inject(RecurringDepositsService);
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private rateRegisterService = inject(RateRegisterService);
/** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */ /** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */
private get entityFetchServices(): EntityFetchServices { private get entityFetchServices(): EntityFetchServices {
@@ -196,10 +216,22 @@ export class ViewCheckerInboxComponent {
/** UPDATE review: the fields this command actually moves, as OLD -> NEW. */ /** UPDATE review: the fields this command actually moves, as OLD -> NEW. */
changes: CommandChange[] = []; 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. */ /** True once the current entity was read and a real comparison is on screen. */
showChanges = false; showChanges = false;
/** True while the current entity is being fetched for comparison. */ /** True while the current entity is being fetched for comparison. */
comparing = false; 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. */ /** True when the screen is describing a record that is about to be deleted. */
deletingEntity = false; deletingEntity = false;
/** CREATE review: submitted data bucketed into readable sections. */ /** CREATE review: submitted data bucketed into readable sections. */
@@ -354,6 +386,8 @@ export class ViewCheckerInboxComponent {
this.comparing = false; this.comparing = false;
this.deletingEntity = false; this.deletingEntity = false;
this.createSections = []; this.createSections = [];
this.changesBaseline = '';
this.rateDiff = null;
// commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined) // 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. // throws — which as a route only broke the page, but in a dialog would leave an empty shell open.
try { try {
@@ -377,10 +411,16 @@ export class ViewCheckerInboxComponent {
} else if (action === 'DELETE') { } else if (action === 'DELETE') {
this.loadDeleteSummary(detail); this.loadDeleteSummary(detail);
} else if (action === 'CREATE') { } else if (action === 'CREATE') {
if (isLoanBaseInterestRateCreate(detail?.entityName, detail?.actionName)) {
this.loadLoanRateRegisterChanges(detail);
}
this.createSections = this.buildCreateSections(this.jsondata); this.createSections = this.buildCreateSections(this.jsondata);
if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') { if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
this.resolveGlAccountCreateLabels(detail, this.jsondata); this.resolveGlAccountCreateLabels(detail, this.jsondata);
} }
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
this.resolveDelinquencyBucketCreateRanges(detail);
}
} }
} }
@@ -393,6 +433,10 @@ export class ViewCheckerInboxComponent {
* entity showed before. * entity showed before.
*/ */
private loadUpdateChanges(detail: any): void { private loadUpdateChanges(detail: any): void {
if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') {
this.loadDelinquencyBucketChanges(detail);
return;
}
const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices); const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
if (!request$) { if (!request$) {
return; 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. * 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. * Reads the record instead and shows the few fields that identify it.
@@ -17,7 +17,12 @@ import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute';
installTdsConfigFixture(); installTdsConfigFixture();
const FY = '2025-26'; const FY = '2025-26';
const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; const Q = [
'2025-06-30',
'2025-09-30',
'2025-12-31',
'2026-03-31'
];
const BOOK: TdsDeductee[] = [ const BOOK: TdsDeductee[] = [
{ {
@@ -94,13 +99,21 @@ function build() {
describe('TDS register workbook', () => { describe('TDS register workbook', () => {
it('has the four sheets a reader expects, in order', () => { it('has the four sheets a reader expects, in order', () => {
expect(build().worksheets.map((w) => w.name)).toEqual(['Summary', 'Customers', 'Accounts', 'Form 121 given']); expect(build().worksheets.map((w) => w.name)).toEqual([
'Summary',
'Customers',
'Accounts',
'Form 121 given'
]);
}); });
it('lists one row per customer, with the figures as numbers and a live total', () => { it('lists one row per customer, with the figures as numbers and a live total', () => {
const ws = build().getWorksheet('Customers')!; const ws = build().getWorksheet('Customers')!;
expect(ws.getRow(3).getCell(2).value).toBe('Customer name'); expect(ws.getRow(3).getCell(2).value).toBe('Customer name');
const at = [4, 5].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; const at = [
4,
5
].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!;
const ramesh = ws.getRow(at); const ramesh = ws.getRow(at);
expect(ramesh.getCell(2).value).toBe('Ramesh'); expect(ramesh.getCell(2).value).toBe('Ramesh');
expect(ramesh.getCell(7).value).toBe(60_000); expect(ramesh.getCell(7).value).toBe(60_000);
@@ -115,8 +128,21 @@ describe('TDS register workbook', () => {
it('lists every account behind the figures, saying which ones count', () => { it('lists every account behind the figures, saying which ones count', () => {
const ws = build().getWorksheet('Accounts')!; const ws = build().getWorksheet('Accounts')!;
const byAccount = new Map([4, 5, 6].map((n) => [ws.getRow(n).getCell(4).value, ws.getRow(n)])); const byAccount = new Map(
expect([...byAccount.keys()].sort()).toEqual(['FD11', 'FD41', 'SB12']); [
4,
5,
6
].map((n) => [
ws.getRow(n).getCell(4).value,
ws.getRow(n)
])
);
expect([...byAccount.keys()].sort()).toEqual([
'FD11',
'FD41',
'SB12'
]);
expect(byAccount.get('SB12')!.getCell(6).value).toBe('No'); expect(byAccount.get('SB12')!.getCell(6).value).toBe('No');
expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes'); expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes');
expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date); expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date);
+98 -24
View File
@@ -70,11 +70,12 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] }); const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] });
ws.columns = [ ws.columns = [
{ width: 46 }, { width: 46 },
{ width: 22 } { width: 22 }];
];
title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2); title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2);
ws.addRow([`Branch: ${input.scopeLabel}`]); ws.addRow([`Branch: ${input.scopeLabel}`]);
ws.addRow([`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`]); ws.addRow([
`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`
]);
ws.getRow(2).font = { color: { argb: 'FF555555' } }; ws.getRow(2).font = { color: { argb: 'FF555555' } };
ws.getRow(3).font = { color: { argb: 'FF555555' } }; ws.getRow(3).font = { color: { argb: 'FF555555' } };
ws.addRow([]); ws.addRow([]);
@@ -82,20 +83,67 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const s = input.summary; const s = input.summary;
section(ws, 'Key figures'); section(ws, 'Key figures');
const figures: [string, number, string?][] = [ const figures: [string, number, string?][] = [
['Customers in this report', s.deductees], [
[' of whom have an FD / RD being tracked', s.trackedCustomers], 'Customers in this report',
['FD / RD interest received this year', s.applicableInterest, MONEY], s.deductees
['Savings interest (shown only — no TDS)', s.exemptInterest, MONEY], ],
['Customers over the TDS limit', s.crossed], [
['TDS due by the rules', s.totalTds, MONEY], ' of whom have an FD / RD being tracked',
['TDS actually deducted from accounts', s.actualTds, MONEY], s.trackedCustomers
['Difference (due − deducted) — should be 0', s.totalTds - s.actualTds, MONEY], ],
['Customers with Form 121 / 15G / 15H', s.declarations], [
['TDS not deducted because of those forms', s.tdsSaved, MONEY], 'FD / RD interest received this year',
['Interest paid without TDS (report in the return)', s.paidWithoutDeduction, MONEY] s.applicableInterest,
MONEY
],
[
'Savings interest (shown only — no TDS)',
s.exemptInterest,
MONEY
],
[
'Customers over the TDS limit',
s.crossed
],
[
'TDS due by the rules',
s.totalTds,
MONEY
],
[
'TDS actually deducted from accounts',
s.actualTds,
MONEY
],
[
'Difference (due − deducted) — should be 0',
s.totalTds - s.actualTds,
MONEY
],
[
'Customers with Form 121 / 15G / 15H',
s.declarations
],
[
'TDS not deducted because of those forms',
s.tdsSaved,
MONEY
],
[
'Interest paid without TDS (report in the return)',
s.paidWithoutDeduction,
MONEY
]
]; ];
for (const [label, value, fmt] of figures) { for (const [
const r = ws.addRow([label, value]); label,
value,
fmt
] of figures) {
const r = ws.addRow([
label,
value
]);
r.getCell(2).numFmt = fmt ?? '#,##0'; r.getCell(2).numFmt = fmt ?? '#,##0';
r.getCell(2).alignment = { horizontal: 'right' }; r.getCell(2).alignment = { horizontal: 'right' };
boxRow(r, 2); boxRow(r, 2);
@@ -110,13 +158,31 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
if (input.rules) { if (input.rules) {
section(ws, 'Rules for this year'); section(ws, 'Rules for this year');
const rules: [string, string][] = [ const rules: [string, string][] = [
['TDS limit — below 60', rupees(input.rules.general)], [
['TDS limit — senior citizen (60+)', rupees(input.rules.senior)], 'TDS limit — below 60',
['Rate with PAN', `${input.rules.rate}%`], rupees(input.rules.general)
['Rate without PAN', `${input.rules.rateNoPan}%`] ],
[
'TDS limit — senior citizen (60+)',
rupees(input.rules.senior)
],
[
'Rate with PAN',
`${input.rules.rate}%`
],
[
'Rate without PAN',
`${input.rules.rateNoPan}%`
]
]; ];
for (const [label, value] of rules) { for (const [
const r = ws.addRow([label, value]); label,
value
] of rules) {
const r = ws.addRow([
label,
value
]);
r.getCell(2).alignment = { horizontal: 'right' }; r.getCell(2).alignment = { horizontal: 'right' };
boxRow(r, 2); boxRow(r, 2);
} }
@@ -253,7 +319,12 @@ function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
{ header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true }, { header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true },
{ header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true } { header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true }
]; ];
const ws = table(wb, 'Form 121 given', `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, cols); const ws = table(
wb,
'Form 121 given',
`Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`,
cols
);
input.declaredRows.forEach((r, i) => { input.declaredRows.forEach((r, i) => {
const line = ws.addRow([ const line = ws.addRow([
@@ -276,7 +347,10 @@ function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
} }
}); });
if (input.declaredRows.length === 0) { if (input.declaredRows.length === 0) {
const r = ws.addRow(['', 'No customer has a declaration on record for this year.']); const r = ws.addRow([
'',
'No customer has a declaration on record for this year.'
]);
r.font = { italic: true, color: { argb: 'FF777777' } }; r.font = { italic: true, color: { argb: 'FF777777' } };
} else { } else {
totals(ws, cols, input.declaredRows.length); totals(ws, cols, input.declaredRows.length);
@@ -49,11 +49,12 @@
account is which, and why. account is which, and why.
</li> </li>
<li> <li>
Only the interest from <strong>fixed and recurring deposits that reaches the customer's savings account</strong> Only the interest from
<strong>fixed and recurring deposits that reaches the customer's savings account</strong>
is tracked — counted on the day it arrives. The savings account's own interest is shown for information but is tracked — counted on the day it arrives. The savings account's own interest is shown for information but
never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to
savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is not savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is
taxed. not taxed.
</li> </li>
@if (rules) { @if (rules) {
<li> <li>
@@ -102,8 +103,11 @@
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div> <div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS due by the rules</div> <div class="tdsr__kpi-l">TDS due by the rules</div>
</div> </div>
<div class="tdsr__kpi" [class.tdsr__kpi--ok]="summary.actualTds === summary.totalTds" <div
[class.tdsr__kpi--warn]="summary.actualTds !== summary.totalTds"> class="tdsr__kpi"
[class.tdsr__kpi--ok]="summary.actualTds === summary.totalTds"
[class.tdsr__kpi--warn]="summary.actualTds !== summary.totalTds"
>
<div class="tdsr__kpi-n">₹{{ summary.actualTds | number: '1.0-0' }}</div> <div class="tdsr__kpi-n">₹{{ summary.actualTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l"> <div class="tdsr__kpi-l">
Actually deducted from accounts Actually deducted from accounts
@@ -332,8 +336,11 @@
} @else if (row.schedule.totalTds || row.actualTds) { } @else if (row.schedule.totalTds || row.actualTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong> <strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div> <div class="small muted">due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
<div class="small" [class.tdsr__ok]="row.actualTds === row.schedule.totalTds" <div
[class.tdsr__warn-text]="row.actualTds !== row.schedule.totalTds"> class="small"
[class.tdsr__ok]="row.actualTds === row.schedule.totalTds"
[class.tdsr__warn-text]="row.actualTds !== row.schedule.totalTds"
>
₹{{ row.actualTds | number: '1.0-0' }} deducted ₹{{ row.actualTds | number: '1.0-0' }} deducted
</div> </div>
} @else if (row.tdsSaved) { } @else if (row.tdsSaved) {
@@ -499,8 +506,8 @@
} @else if (selectedTab === 'paying') { } @else if (selectedTab === 'paying') {
No customer needs TDS deducted for these filters. Check the "No TDS" tab. No customer needs TDS deducted for these filters. Check the "No TDS" tab.
} @else if (selectedTab === 'declared') { } @else if (selectedTab === 'declared') {
No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's Form
Form 121 tab. 121 tab.
} @else { } @else {
No customers match these filters. No customers match these filters.
} }
@@ -226,7 +226,10 @@ export class TdsInterestReportComponent implements OnInit {
if (row.hasDeclaration) { if (row.hasDeclaration) {
const beforeForm = Math.round(row.schedule.totalTds - row.actualTds); const beforeForm = Math.round(row.schedule.totalTds - row.actualTds);
if (beforeForm > 0) { if (beforeForm > 0) {
return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' }; return {
label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`,
tone: 'warn'
};
} }
if (row.tdsSaved > 0) { if (row.tdsSaved > 0) {
return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' }; return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' };
@@ -244,7 +247,10 @@ export class TdsInterestReportComponent implements OnInit {
return { label: 'Over the limit — TDS deducted', tone: 'ok' }; return { label: 'Over the limit — TDS deducted', tone: 'ok' };
} }
if (row.actualTds > 0) { if (row.actualTds > 0) {
return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' }; return {
label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`,
tone: 'warn'
};
} }
if (row.hasDeclaration && row.crossed) { if (row.hasDeclaration && row.crossed) {
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' }; return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
@@ -45,8 +45,8 @@
<div class="tdsr__banner tdsr__banner--warn"> <div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="info-circle"></fa-icon> <fa-icon icon="info-circle"></fa-icon>
<div> <div>
<strong>This report is for Head Office.</strong> TDS is paid to the Government by Head Office for the whole bank, <strong>This report is for Head Office.</strong> TDS is paid to the Government by Head Office for the whole
under one TAN. Your branch's customers and the TDS deducted from them are on bank, under one TAN. Your branch's customers and the TDS deducted from them are on
<em>Reports › Deposit Interest &amp; TDS</em>. <em>Reports › Deposit Interest &amp; TDS</em>.
</div> </div>
</div> </div>
@@ -123,7 +123,9 @@
<fa-icon icon="check-circle"></fa-icon> <fa-icon icon="check-circle"></fa-icon>
<div> <div>
<strong>Reconciled.</strong> <strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ summary.totalDeposited | number: '1.0-0' }} ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
summary.totalDeposited | number: '1.0-0'
}}
paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date. paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
</div> </div>
</div> </div>
@@ -144,9 +146,10 @@
<div class="tdsr__banner tdsr__banner--warn"> <div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> <fa-icon icon="exclamation-triangle"></fa-icon>
<div> <div>
By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but ₹{{
₹{{ summary.totalDeducted | number: '1.0-0' }} was actually deducted from accounts. Open summary.totalDeducted | number: '1.0-0'
<em>Deposit Interest &amp; TDS</em> to see which customers differ. }}
was actually deducted from accounts. Open <em>Deposit Interest &amp; TDS</em> to see which customers differ.
</div> </div>
</div> </div>
} }
@@ -198,12 +201,16 @@
<tbody> <tbody>
@for (row of rows; track trackRow($index, row)) { @for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue"> <tr [class.tdsr__row--warn]="row.late || row.overdue">
<td class="small"><strong>{{ quarterLabel[row.quarter] }}</strong></td> <td class="small">
<strong>{{ quarterLabel[row.quarter] }}</strong>
</td>
<td class="small"> <td class="small">
{{ row.month + '-01' | date: 'MMM y' }} {{ row.month + '-01' | date: 'MMM y' }}
<div class="muted">{{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}</div> <div class="muted">{{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}</div>
</td> </td>
<td class="num"><strong>₹{{ row.deducted | number: '1.0-2' }}</strong></td> <td class="num">
<strong>₹{{ row.deducted | number: '1.0-2' }}</strong>
</td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td> <td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="mono small"> <td class="mono small">
@for (c of row.challans; track c.id) { @for (c of row.challans; track c.id) {
@@ -330,9 +337,9 @@
} }
</div> </div>
<div class="tdsr__foot"> <div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the The "without deduction" line is not optional — interest paid gross against a declaration must be reported in
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of the quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current of declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file. specification before generating the file.
</div> </div>
</div> </div>
@@ -153,7 +153,8 @@ export class TdsRemittanceReportComponent implements OnInit {
}, },
error: (err: any) => { error: (err: any) => {
this.saving = false; this.saving = false;
this.formError = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; this.formError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
} }
}); });
} }
@@ -172,7 +173,16 @@ export class TdsRemittanceReportComponent implements OnInit {
} }
private blankForm(): NewTdsChallan { private blankForm(): NewTdsChallan {
return { fy: this.fy, month: '', amount: 0, challanNo: '', bsrCode: '', depositedOn: '', paidFromGlAccountId: 0, remarks: '' }; return {
fy: this.fy,
month: '',
amount: 0,
challanNo: '',
bsrCode: '',
depositedOn: '',
paidFromGlAccountId: 0,
remarks: ''
};
} }
trackRow(_: number, row: TdsRemittanceRow): string { trackRow(_: number, row: TdsRemittanceRow): string {
@@ -228,19 +238,58 @@ export class TdsRemittanceReportComponent implements OnInit {
recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]); recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]);
recon.addRow([]); recon.addRow([]);
const lines: [string, number | string][] = [ const lines: [string, number | string][] = [
['Deductees', s.deductees], [
['Total interest paid on time deposits', s.totalInterestPaid], 'Deductees',
[' — with tax deducted', s.interestWithDeduction], s.deductees
[' — without deduction (declaration on record)', s.interestWithoutDeduction], ],
['TDS at standard rate (PAN on record)', s.tdsAtStandardRate], [
['TDS at higher rate (no PAN)', s.tdsAtHigherRate], 'Total interest paid on time deposits',
['TDS due by the rules', s.calculatedTds], s.totalInterestPaid
['Total TDS actually deducted', s.totalDeducted], ],
['Total deposited with Government', s.totalDeposited], [
['Still within due date', s.pendingDeposit], ' — with tax deducted',
['Overdue', s.overdueAmount], s.interestWithDeduction
['DIFFERENCE (must be zero)', s.difference], ],
['Interest payable for late deposit', s.defaultInterest] [
' — without deduction (declaration on record)',
s.interestWithoutDeduction
],
[
'TDS at standard rate (PAN on record)',
s.tdsAtStandardRate
],
[
'TDS at higher rate (no PAN)',
s.tdsAtHigherRate
],
[
'TDS due by the rules',
s.calculatedTds
],
[
'Total TDS actually deducted',
s.totalDeducted
],
[
'Total deposited with Government',
s.totalDeposited
],
[
'Still within due date',
s.pendingDeposit
],
[
'Overdue',
s.overdueAmount
],
[
'DIFFERENCE (must be zero)',
s.difference
],
[
'Interest payable for late deposit',
s.defaultInterest
]
]; ];
for (const line of lines) { for (const line of lines) {
recon.addRow(line); recon.addRow(line);
+76 -12
View File
@@ -25,7 +25,12 @@ installTdsConfigFixture();
* shapes ("some row crossed") because nobody could know the numbers. * shapes ("some row crossed") because nobody could know the numbers.
*/ */
const FY = '2025-26'; const FY = '2025-26';
const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; const Q = [
'2025-06-30',
'2025-09-30',
'2025-12-31',
'2026-03-31'
];
const fd = (id: number, amounts: number[], extra: object = {}) => ({ const fd = (id: number, amounts: number[], extra: object = {}) => ({
id, id,
@@ -68,23 +73,70 @@ function person(
const BOOK: TdsDeductee[] = [ const BOOK: TdsDeductee[] = [
// Over the limit in Q4, plus a savings account that must stay outside the figure. // Over the limit in Q4, plus a savings account that must stay outside the figure.
person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [ person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [
{ ...fd(11, [15_000, 15_000, 15_000, 15_000]), deductions: [{ date: Q[3], amount: 6_000 }] }, {
...fd(
11,
[
15_000,
15_000,
15_000,
15_000
]
),
deductions: [{ date: Q[3], amount: 6_000 }]
},
sb(12, 1_200) sb(12, 1_200)
]), ]),
// Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little. // Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little.
person(2, 'Sunita', '1955-01-01', '', 'bhopal', [ person(2, 'Sunita', '1955-01-01', '', 'bhopal', [
{ ...fd(21, [30_000, 30_000, 30_000, 30_000]), deductions: [{ date: Q[3], amount: 20_000 }] } {
...fd(
21,
[
30_000,
30_000,
30_000,
30_000
]
),
deductions: [{ date: Q[3], amount: 20_000 }]
}
]), ]),
// ₹6,000 short of the limit: the customer to ask for a declaration. // ₹6,000 short of the limit: the customer to ask for a declaration.
person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [fd(31, [22_000, 22_000])]), person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [
fd(
31,
[
22_000,
22_000
]
)
]),
// Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross. // Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross.
person(4, 'Kamala', '1979-07-07', 'ABCPK9012M', 'Head Office', [fd(41, [20_000, 20_000, 20_000, 20_000])], { person(
[FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } 4,
}), 'Kamala',
'1979-07-07',
'ABCPK9012M',
'Head Office',
[
fd(
41,
[
20_000,
20_000,
20_000,
20_000
]
)
],
{
[FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' }
}
),
// A minor's deposit, already listed under the guardian by the platform. // A minor's deposit, already listed under the guardian by the platform.
person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [ person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [
{ ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' } { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' }]),
]),
// Savings only, no PAN: never deducted, so never counted as a no-PAN problem. // Savings only, no PAN: never deducted, so never counted as a no-PAN problem.
person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)]) person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)])
]; ];
@@ -201,7 +253,10 @@ describe('the summary', () => {
describe('filters', () => { describe('filters', () => {
it('narrows to a branch by office name, whatever its case, without re-aggregating', () => { it('narrows to a branch by office name, whatever its case, without re-aggregating', () => {
const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' })); const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' }));
expect(scoped.map((r) => r.deductee.name).sort()).toEqual(['Sunita', 'Vitthal']); expect(scoped.map((r) => r.deductee.name).sort()).toEqual([
'Sunita',
'Vitthal'
]);
expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000); expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000);
}); });
@@ -211,7 +266,11 @@ describe('filters', () => {
tdsInterestRows(filter({ crossedOnly: true })) tdsInterestRows(filter({ crossedOnly: true }))
.map((r) => r.deductee.name) .map((r) => r.deductee.name)
.sort() .sort()
).toEqual(['Kamala', 'Ramesh', 'Sunita']); ).toEqual([
'Kamala',
'Ramesh',
'Sunita'
]);
}); });
it('searches on name, customer ID and PAN', () => { it('searches on name, customer ID and PAN', () => {
@@ -242,7 +301,12 @@ describe('remittance', () => {
}); });
it('marks a month paid in full and on time', () => { it('marks a month paid in full and on time', () => {
const [r] = tdsRemittanceRows(FY, [{ month: '2026-03', amount: 26_000, postings: 2 }], [challan({ amount: 26_000 })], '2026-06-01'); const [r] = tdsRemittanceRows(
FY,
[{ month: '2026-03', amount: 26_000, postings: 2 }],
[challan({ amount: 26_000 })],
'2026-06-01'
);
expect(r.quarter).toBe(4); expect(r.quarter).toBe(4);
expect(r.outstanding).toBe(0); expect(r.outstanding).toBe(0);
expect(r.late).toBe(false); expect(r.late).toBe(false);
+7 -4
View File
@@ -446,7 +446,12 @@ export function tdsRemittanceRows(
asAt: string asAt: string
): TdsRemittanceRow[] { ): TdsRemittanceRow[] {
const live = challans.filter((c) => !c.reversed && c.fy === fy); const live = challans.filter((c) => !c.reversed && c.fy === fy);
const months = [...new Set([...deducted.map((d) => d.month), ...live.map((c) => c.month)])].sort(); const months = [
...new Set([
...deducted.map((d) => d.month),
...live.map((c) => c.month)
])
].sort();
return months.map((month) => { return months.map((month) => {
const d = deducted.find((x) => x.month === month); const d = deducted.find((x) => x.month === month);
@@ -533,9 +538,7 @@ export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], asAt:
const higher = rows.filter((r) => !r.panOnRecord); const higher = rows.filter((r) => !r.panOnRecord);
const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0)); const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0));
const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0)); const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0));
const pendingDeposit = round2( const pendingDeposit = round2(remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0));
remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0)
);
const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0)); const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0));
return { return {
+3 -1
View File
@@ -120,7 +120,9 @@ export class TdsReportsService {
} }
createChallan(challan: NewTdsChallan): Observable<TdsChallan[]> { createChallan(challan: NewTdsChallan): Observable<TdsChallan[]> {
return this.http.post<any[]>('/tds/challans', challan).pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); return this.http
.post<any[]>('/tds/challans', challan)
.pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
} }
reverseChallan(id: number): Observable<TdsChallan[]> { reverseChallan(id: number): Observable<TdsChallan[]> {