Compare commits

...
Author SHA1 Message Date
sonaliandClaude Sonnet 5 606d14b995 feat(loan-products): build out Loans A/C (EMI) stepper and wire it to the rate register
Adds the curated Loans A/C steps (security/collateral, amount & tenure,
repayment & EMI, interest, prepayment & closure) and a product-type
picker (loan-product-types.ts) shared by the create wizard, product
list and account picker. The interest step and a new register-rate-view
read the live Loan Interest Rate Register for a product's chart/CIBIL
rate instead of a hardcoded value, and a new loan-product-catalog
service + template card back the loan account stepper's product pick.

Also carries forward in-progress Loan Interest Rate Register UI work:
an inline Add Slab panel (replacing window.prompt) and a Submit Details
dialog for confirming name/effective dates before submitting for
approval.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:53:43 +05:30
sonaliandClaude Sonnet 5 afcd3af918 feat(delinquency): maker-checker UI for Delinquency Ranges and Buckets
Checker inbox now resolves DELINQUENCY_RANGE/DELINQUENCY_BUCKET entries
to readable diffs instead of raw JSON, and each bucket/range screen
shows its own pending-approval state via a new pending-requests panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:52:11 +05:30
sonaliandClaude Sonnet 5 d61584f291 feat(loan-products): wire GL mapping, checker diff view, and approval flow for loan interest rate register
- Add GL account picker (gl-picker.component.ts) for mapping each product row to a GL account
- Extract shared diff logic into rate-register-diff.util.ts + loan-rate-register-diff.util.ts
- Add rate-register-diff component to render before/after changes for checker approval
- Rewire checker-inbox and view-checker-inbox to show loan interest rate register diffs
- Refactor rate-register.service.ts (mock-to-real HTTP wiring, GL-aware payload mapping)
- Simplify preview-register component, moving diff rendering into the shared diff component
- Add glAccountId field to RateChartRow model

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 17:22:59 +05:30
sonali a5a64d4efe Merge branch 'dev-latest' into feature/advanceLoanProduct 2026-10-07 15:44:36 +05:30
sonali 0ea57b3f82 Add interest loan related UI 2026-10-07 15:30:15 +05:30
keshav 6b0f218b70 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:13:37 +05:30
keshav 6226682b36 feat: updates for cashier, tds reports, rbi reserve and dashboard 2026-10-07 10:13:28 +05:30
niteshandClaude Sonnet 5 d6d7d912c0 fix(savings-product): guard against duplicate submission on create
The product name is unique in the database, so a repeat click on Submit
while the first request is still in flight fails with a duplicate key
error even though the first request already created the product. Disables
the button and ignores repeat calls until the request settles; also drops
a stray debug console.log from the submit payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:39:43 +05:30
nitesh 454988d803 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-06 11:01:00 +05:30
niteshandClaude Sonnet 5 17bfe4c7e6 fix(tasks): separate Pending Share Accounts from the Pending Account screen
Removes the Pending Share Accounts section that had been bolted onto the
Pending Account screen (duplicate data/actions alongside the normal-account
table), restoring it as its own standalone queue. The standalone
pending-share-accounts route/component already existed but had no tab link
anywhere in the Approval Center nav, so it was unreachable from the UI -
added a "Pending Share Accounts" tab for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:56:25 +05:30
Sonali LikhitkarandClaude Sonnet 5.5 7c868ae073 feat(loan-products): add loan interest rate register UI
- Add Loan Interest Rate Register screen with CIBIL adjustment and edit-register views, backed by rate-register service and model
- Wire the new screen into products routing and the products menu
- Update loan product details and advance limit/tenure steps

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:20:36 +05:30
keshav 5ab4e9eaf0 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-05 12:41:38 +05:30
keshav 4fc6784695 feat: interest posting 2026-10-05 12:39:14 +05:30
nitesh 174a2e7b6b fix(accounting,deposits): allow future dates in premature close and GL search, fix FD/RD accounting mappings 2026-10-04 16:02:15 +05:30
nitesh 9f7d9d28fd feat: Fixed Deposit - premature close, payment dialog, account actions, translations and env updates 2026-10-04 09:12:06 +05:30
keshav 1ffac4b1e5 fix(savings-product): move savings interest paid to expenses mapping 2026-10-01 14:01:24 +05:30
niteshandClaude Sonnet 5 4dee1db360 Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Brings in partner's RBI Reserve / Form 1 & 9 returns UI and interest
posting reports, alongside today's FD premature-closure work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:22:39 +05:30
niteshandClaude Sonnet 5 0c6ebfdc1a Merge branch 'feature/bugfix' into dev-latest
Brings in partner's "Enforce same-user maker-checker restriction" and
related client-KYC-step / product-edit-diff-dialog work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:21:19 +05:30
keshavandClaude Opus 5.5 b6994c0b76 feat(rbi-reserve,savings): Form 1/Form 9 returns UI, branch-wise interest posting reports and summary
- Reports > Interest Posting Reports: per branch and product, generate/regenerate
  at any time, approve; branch column, HO-on-behalf marker, posted txn per account
- Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export
- Day Close warns and disables Close Day while the branch's interest report is unapproved
- Savings product accounting: Interest On Savings (expense), Savings Interest Payable
  and Paid (liability rows, required)
- RBI Form 1 statutory and Form 9 return screens, reserve config updates
- Interest posting report GETs exempted from the response cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:20:01 +05:30
niteshandClaude Sonnet 5 4f02f24aae feat(deposits): RBI premature closure compliance UI, renewal-history status fix
Premature Close dialog: adds the Closure Reason dropdown, the explicit
Waive Penalty checkbox, a tenure/date preview panel, and an "Effective
Rate for this closure" display so the maker sees exactly what rate and
penalty treatment is about to apply.

Renewal History tab: the latest term's card kept showing an ACTIVE badge
even after the account was prematurely closed, since the archived
renewal-status column only ever updates on an actual renewal rollover.
For the last card only, status now cross-checks the account's own live
status instead. "Projected Maturity" now reads "Closed At" once that
term has prematurely closed, rather than implying it's still a projection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:19:37 +05:30
niteshandClaude Sonnet 5 74a51a0626 fix(products): show named GL account changes in the pending-edit diff dialog
otherGlMappings (the nested document behind Savings/Current/FD/RD Interest
Payable/Paid and similar named accounts) was excluded from the generic
field diff entirely, and the backend was silently dropping its value
anyway (see the paired backend fix). Now that the backend correctly
stages this field, diff it explicitly: find each reserved account name
across original.otherGlMappingsMap and proposed.otherGlMappings, resolve
the GL code to a readable name, and show a row only when it actually
moved -- same convention as every other field in this dialog, instead of
a raw JSON blob or nothing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:40:10 +05:30
Sonali LikhitkarandClaude Sonnet 5.5 283ab2f733 feat(loan-products): add advance loan product stepper steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 18:06:08 +05:30
196 changed files with 22063 additions and 3682 deletions
@@ -279,7 +279,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,13 +353,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -368,7 +362,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.maxDate = null;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
@@ -17,7 +17,12 @@
</p>
</div>
</div>
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
<button
class="bor-btn bor-btn--xls"
type="button"
[disabled]="exporting || !filtered.length"
(click)="exportExcel()"
>
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
@@ -132,7 +137,9 @@
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td>
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
</td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -107,11 +107,26 @@
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="startClose(openDay)"
[disabled]="interestPending.length > 0"
>
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
@if (interestPending.length > 0) {
<div class="bd-interest-alert">
<mat-icon>warning</mat-icon>
<span>
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
closing the day.
</span>
</div>
}
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
@@ -372,3 +372,30 @@
color: #d97706;
}
}
// Shown under the open day while a savings interest posting report due today is unapproved.
.bd-interest-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0 12px;
padding: 10px 14px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
color: #92400e;
font-size: 13px;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
@@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit {
return this.totalOf(this.closeCounts);
}
/**
* Savings products whose interest posting report is due today and not yet approved. The close is
* refused by the backend while any is pending; this only says so before the user tries.
*/
interestPending: string[] = [];
private checkInterestReports(): void {
if (!this.openDay?.businessDate) {
this.interestPending = [];
return;
}
this.service
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
}
load(): void {
this.loading = true;
this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
this.checkInterestReports();
});
this.service
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = new Date();
const d = businessToday();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -63,7 +64,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -82,7 +83,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
+17
View File
@@ -205,6 +205,23 @@ export class CashierService {
return this.http.get('/branchday', { params });
}
/**
* The savings products whose interest posting report is due for the date and not yet approved —
* the backend refuses to close any branch's day while one is pending.
*/
interestDayCloseCheck(
date?: string,
officeId?: number
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
'/savingsinterestpostingreports/day-close-check',
{ params }
);
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -13,6 +13,7 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -95,7 +96,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -79,7 +80,7 @@ export class InterCashierTransferComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
+2 -1
View File
@@ -14,6 +14,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -357,7 +358,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -18,6 +18,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -85,7 +86,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -33,6 +33,7 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
today = businessTodayIso();
fromDate = '';
toDate = this.today;
@@ -1870,7 +1871,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
@@ -2103,7 +2104,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = new Date();
const d = businessToday();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2837,7 +2838,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2969,7 +2970,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3068,7 +3069,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3391,7 +3392,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3512,7 +3513,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -4173,7 +4174,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4549,7 +4550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -5071,7 +5072,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = new Date();
const today = businessToday();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -5959,7 +5960,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6154,7 +6155,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
@@ -6259,7 +6260,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6370,7 +6371,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which side of the register a screen shows.
@@ -227,7 +228,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = new Date();
const now = businessToday();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -56,8 +56,8 @@
<mat-icon>info_outline</mat-icon>
<div>
@if (configMissing) {
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under
Organization › TDS Configuration.
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
› TDS Configuration.
} @else if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
@@ -116,8 +116,8 @@
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deduct on their own and ignore the declaration — remove the tax group from them.
</div>
</div>
}
@@ -192,20 +192,90 @@
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
@if (declarationRecord; as d) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
{{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
credited before it is taxed as usual.
</span>
</div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,3 +332,61 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
/* ── Recording a declaration ── */
.f121__btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&--warn {
border-color: #b45309;
color: #b45309;
}
}
.f121__decl-form {
display: grid;
gap: 10px;
padding: 12px 16px;
label {
display: grid;
gap: 4px;
font-size: 12px;
span {
color: #6b7280;
font-weight: 600;
}
input {
max-width: 280px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
}
}
.f121__decl-actions {
display: flex;
gap: 8px;
}
@@ -23,6 +23,8 @@ import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */
import {
@@ -111,6 +113,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>();
@@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
// ── Declarations on record ──
/**
* Every declaration the customer has given, each for one year and from the day the branch
* accepted it. The KYC record only ever held a type and a year, which made a September
* declaration shield interest back to April and let last year's form run on.
*/
declarations: TdsDeclarationRecord[] = [];
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
eligibility: EligibilityResult | null = null;
@@ -157,9 +162,9 @@ export class Form121TabComponent implements OnInit, OnDestroy {
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private platformByFy = new Map<string, TdsDeductee | null>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -182,13 +187,15 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
switchMap(({ client, kyc, accounts, declarations }) => {
this.applyClient(client);
this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
@@ -212,12 +219,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
@@ -251,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
this.platformByFy = new Map<string, TdsDeductee | null>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -272,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -289,8 +293,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
}
}
@@ -313,12 +316,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
this.accountInputs.push({ ...ref, credits, taxLines, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
this.fy =
this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
}
}
@@ -338,11 +342,33 @@ export class Form121TabComponent implements OnInit, OnDestroy {
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
// the computation waits for them.
if (!this.platformByFy.has(year)) {
this.loadPlatformYear(year);
return;
}
const platform = this.platformByFy.get(year);
for (const a of this.accountInputs) {
if (!a.meta.tdsApplicable) {
continue;
}
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
a.credits = match?.credits ?? [];
a.taxLines = match?.deductions ?? [];
}
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
// The selected year only - this used to total every year the account had ever been taxed in.
a.meta.tdsDeducted = a.meta.tdsApplicable
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
: 0;
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
// What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth || this.configMissing) {
return;
@@ -382,21 +408,95 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
private loadPlatformYear(year: string): void {
this.tdsService
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.pipe(
catchError(() => of([])),
takeUntil(this.destroy$)
)
.subscribe((rows) => {
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
this.recompute();
});
}
/** The live declaration recorded for the year being shown, if any. */
get declarationRecord(): TdsDeclarationRecord | null {
if (!this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
get declarationOnRecord(): boolean {
return !!this.declarationRecord;
}
/** The declaration as the engine takes it — operative from the day it was accepted. */
private declarationForSelectedFy(): Declaration | null {
const d = this.declarationRecord;
if (!d) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
}
openDeclarationForm(): void {
this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
this.declarationError = '';
this.showDeclarationForm = true;
}
saveDeclaration(): void {
if (!this.newDeclaration.acceptedOn) {
this.declarationError = 'Enter the date the branch accepted the form.';
return;
}
this.savingDeclaration = true;
this.declarationError = '';
this.tdsService
.createDeclaration({
clientId: Number(this.clientId),
fy: normaliseFy(this.fy),
formType: declarationFormFor(this.fy, this.dateOfBirth),
acceptedOn: this.newDeclaration.acceptedOn,
uin: this.newDeclaration.uin || undefined,
estimatedIncome: this.newDeclaration.estimatedIncome
})
.subscribe({
next: (rows) => {
this.savingDeclaration = false;
this.showDeclarationForm = false;
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => {
this.savingDeclaration = false;
this.declarationError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
revokeDeclaration(): void {
const d = this.declarationRecord;
if (!d) {
return;
}
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
if (!reason) {
return;
}
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
next: (rows) => {
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
});
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -474,11 +574,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
* A declaration is on record but a deposit still carries a stock Fineract tax group. Those
* accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
}
/**
@@ -477,7 +477,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualIncome:
v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
@@ -522,7 +523,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -769,7 +770,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -930,7 +931,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
+19 -1
View File
@@ -31,7 +31,25 @@ export class CacheInterceptor implements HttpInterceptor {
* underlying GL mapping changed — the request was answered from cachedData and the backend was
* never hit again. Matched as a substring of the request URL.
*/
private static readonly NEVER_CACHE = ['/rbireservereturn', '/rbireserveconfig', '/tdsconfig', '/tds/deposit-interest'];
private static readonly NEVER_CACHE = [
'/rbireservereturn',
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday'
];
private isCacheable(url: string): boolean {
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
+16 -3
View File
@@ -663,6 +663,12 @@
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
@@ -2076,6 +2082,8 @@
at month end. -->
<div class="gl-group">
<div class="gl-group-title">Reserve Returns</div>
<!-- The statutory form is a tab on Form 1 rather than an entry of its own: two menu
items for one return had a filer guessing which one they were looking at. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 1 CRR Cash Reserve Ratio')"
@@ -2084,13 +2092,18 @@
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 1 — CRR (fortnightly)
</a>
<!-- Form 9 (SLR) has no entry of its own. Section 24 is settled on the statutory Form 1,
at Part C, on the same liabilities Part B charges the cash reserve on — so the SLR
position is read there rather than on a second screen that computed it from a
differently drawn base and disagreed. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 9 SLR Statutory Liquidity Ratio')"
routerLink="/rbi/form9"
[class.hidden]="!matchesRbiSearch('Form IX R408 Return on Assets and Liabilities balance sheet')"
routerLink="/rbi/form9-return"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 9 — SLR (monthly)
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form IX — Assets &amp;
Liabilities (R408)
</a>
</div>
<!-- CIMS / XBRL Returns -->
@@ -20,7 +20,9 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">
Branch:&nbsp;<strong>{{ officeLabel }}</strong>
</div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -49,6 +49,7 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -92,6 +93,14 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
/** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
get officeLabel(): string {
if (this.authenticationService.isHeadOfficeUser()) {
return 'HO';
}
return this.authenticationService.getCredentials()?.officeName || '—';
}
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
@@ -100,8 +109,10 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Current date for top bar display */
today = new Date();
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Last login time display */
lastLoginTime = this.formatLastLogin();
+30
View File
@@ -0,0 +1,30 @@
/**
* 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 bank's "today": the business date while `enable-business-date` is on, the server's own date
* while it is off. The server decides which (GET /branchday/today); the web app stores the answer
* in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
* the browser clock is the only date there is, so it is the fallback.
*/
export function businessToday(): Date {
const stored = localStorage.getItem('mifosXServerDate');
const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
if (!match) {
return new Date();
}
// Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
// land on the previous day in any timezone behind UTC.
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
/** {@link businessToday} as yyyy-MM-dd. */
export function businessTodayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -268,7 +268,7 @@
<span class="nx-row__label">OD Interest Charged</span>
<span class="nx-row__value nx-row__value--amount">
{{
(currentAccountData.summary.totalOverdraftInterestDerived || 0)
currentAccountData.summary.totalOverdraftInterestDerived || 0
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
@@ -24,6 +24,7 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -97,6 +98,7 @@ export class PaymentDialogComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
@@ -375,7 +377,16 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
next: (response: any) => {
// Parked in the Checker Inbox rather than executed -- see the same handling in
// PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -51,12 +51,53 @@
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Closure Reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of closureReasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loadingAmount) {
<div class="pcd__loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Calculating premature-closure amount...</span>
</div>
} @else {
@if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
<div class="pcd__preview pcd__preview--tenure">
@if (data.activatedOnDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Term Started</span>
<span class="pcd__preview-value">{{ activatedOnDateDisplay }}</span>
</div>
<div class="pcd__preview-row">
<span class="pcd__preview-label">Tenure Completed (this term)</span>
<span class="pcd__preview-value"
>{{ tenureElapsedDisplay }} <span class="pcd__preview-sub">({{ daysElapsed }} days)</span></span
>
</div>
}
@if (data.maturityDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Scheduled Maturity Date</span>
<span class="pcd__preview-value"
>{{ maturityDateDisplay }}
<span class="pcd__preview-sub">({{ daysRemainingToMaturity }} days left)</span></span
>
</div>
}
@if (data.currentBalance !== undefined && data.currentBalance !== null) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Balance (as of today)</span>
<span class="pcd__preview-value">{{ data.currentBalance | formatNumber }}</span>
</div>
}
</div>
}
@if (maturityAmount !== null && maturityAmount !== undefined) {
<div class="pcd__preview">
@if (data.depositAmount) {
@@ -71,12 +112,27 @@
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
</div>
}
@if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
<div class="pcd__preview-row pcd__preview-row--highlight">
<span class="pcd__preview-label">Effective Rate for this closure</span>
<span class="pcd__preview-value">{{ effectiveInterestRate }}%</span>
</div>
}
@if (data.preClosurePenalApplicable) {
<div class="pcd__preview-row">
<div
class="pcd__preview-row"
[class.pcd__preview-row--waived]="prematureCloseForm.get('waivePreClosurePenalty').value"
>
<span class="pcd__preview-label"
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
>
<span class="pcd__preview-value">&minus; {{ data.preClosurePenalInterest }}%</span>
<span class="pcd__preview-value">
@if (prematureCloseForm.get('waivePreClosurePenalty').value) {
<span class="pcd__penalty-waived-tag">Waived</span>
} @else {
&minus; {{ data.preClosurePenalInterest }}%
}
</span>
</div>
}
@if (interestEarned !== null) {
@@ -84,7 +140,9 @@
<span class="pcd__preview-label"
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
>
<span class="pcd__preview-value">+ {{ interestEarned | formatNumber }}</span>
<span class="pcd__preview-value"
>{{ interestEarned >= 0 ? '+' : '' }} {{ interestEarned | formatNumber }}</span
>
</div>
}
<div class="pcd__preview-row pcd__preview-row--total">
@@ -94,6 +152,26 @@
</div>
}
@if (data.preClosurePenalApplicable) {
@if (isAutoWaivedReason) {
<div class="pcd__waive-locked">
<fa-icon icon="check-circle" class="m-r-10"></fa-icon>
<span>
Penalty automatically waived -- <strong>{{ selectedReasonLabel }}</strong> always waives the
pre-closure penalty, regardless of this checkbox.
</span>
</div>
} @else {
<mat-checkbox formControlName="waivePreClosurePenalty" class="pcd__waive-checkbox">
Waive the pre-closure penalty for this closure
<span class="pcd__waive-hint">
Interest recalculates at the full contracted rate instead of the penalty rate above. This does not
change the product's own penalty setting -- it applies to this one closure only.
</span>
</mat-checkbox>
}
}
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
@@ -129,8 +207,8 @@
@if (noDestinationAccounts) {
<p class="pcd__hint pcd__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that
pays out in cash instead.
This customer has no active savings account to transfer into. Premature closure has to be paid into a
savings account, so open one for this customer before closing this deposit.
</p>
}
@@ -153,6 +231,16 @@
</mat-form-field>
}
@if (prematureCloseForm.contains('toGlAccountId')) {
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
[inputLabel]="'Transfer to GL Account'"
>
</mifosx-gl-account-selector>
}
@if (prematureCloseForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
@@ -204,6 +204,86 @@
font-weight: 700;
color: #0f172a;
}
&-sub {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 500;
color: #94a3b8;
margin-left: 4px;
}
}
.pcd__preview--tenure {
margin-bottom: 14px;
}
.pcd__preview-row--waived {
.pcd__preview-label {
color: #16a34a;
}
}
.pcd__penalty-waived-tag {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #15803d;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 999px;
padding: 3px 10px;
}
.pcd__waive-checkbox {
display: flex;
align-items: flex-start;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
margin: 0 0 18px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
::ng-deep .mdc-form-field {
align-items: flex-start;
}
::ng-deep .mdc-checkbox {
margin-top: -9px;
}
}
.pcd__waive-locked {
display: flex;
align-items: flex-start;
gap: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 4px solid #16a34a;
border-radius: 10px;
color: #15803d;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
strong {
color: #166534;
}
}
.pcd__waive-hint {
display: block;
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-top: 3px;
line-height: 1.4;
}
.pcd__section-label {
@@ -23,13 +23,19 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators }
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { GLAccount } from 'app/shared/models/general.model';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's
* nothing to renew): pay out in cash, or transfer the pre-maturity balance to a savings account. Same
* nothing to renew): transfer the pre-maturity balance to a savings account, or post it to a GL account.
* There is deliberately no cash payout -- the backend does not offer or accept one for a premature
* closure, so the proceeds always stay traceable to an account. Same
* `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone
* action's own red accent rather than Payment's blue, since it's an irreversible, usually
* penalty-bearing action rather than a routine one.
@@ -49,24 +55,51 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatDialogClose,
CdkTextareaAutosize,
FaIconComponent,
FormatNumberPipe
FormatNumberPipe,
GlAccountSelectorComponent
]
})
export class PrematureCloseDialogComponent implements OnInit {
/** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly TRANSFER_TO_GL_ID = 600;
private static readonly ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account'
};
/**
* Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
* four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
* discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
* to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
* are gated because they are the product's own opt-in facilities (see SavingsProduct
* deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
* a maker pick it to bypass a penalty that product's terms don't actually exempt.
*/
private static readonly NORMAL_REASON_ID = 100;
private static readonly ALL_CLOSURE_REASONS: {
id: number;
label: string;
autoWaived: boolean;
gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
}[] = [
{ id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
{ id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
{ id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
{ id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
{ id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
];
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
data: {
accountId: number | string;
@@ -76,16 +109,29 @@ export class PrematureCloseDialogComponent implements OnInit {
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
deceasedSettlement?: boolean;
noPenaltyNomineeHeirClosure?: boolean;
activatedOnDate?: any;
maturityDate?: any;
currentBalance?: number;
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private accountingService = inject(AccountingService);
private alertService = inject(AlertService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
/** Postable GL accounts, loaded once -- only read when the GL action is chosen. */
glAccountsData: GLAccount[] = [];
maturityAmount: number = null;
/** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
* penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
* account's unmodified contracted rate. Read fresh on every recalculation. */
effectiveInterestRate: number = null;
loadingAmount = true;
loadFailed = false;
submitting = false;
@@ -99,17 +145,66 @@ export class PrematureCloseDialogComponent implements OnInit {
closedOnDate: [
this.maxDate,
Validators.required
]
],
// Both always present (not added conditionally like onAccountClosureId below) so submit()
// always carries a value, even for a product with no penalty configured at all -- the backend
// ignores them there anyway, since there is nothing to waive.
prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
waivePreClosurePenalty: [false]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
// Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
// should see the actual amount they're about to confirm, not the penalty-applied one, the moment
// they tick the box.
this.prematureCloseForm
.get('waivePreClosurePenalty')
.valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
// Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
// like it, recalculates the preview so the amount shown always matches the reason selected.
this.prematureCloseForm
.get('prematureClosureReasonId')
.valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
// Same date the form already starts with -- without this the amount/options never load unless the
// maker happens to touch the date picker, which the full-page form this replaces required too.
this.calculatePrematureAmount(this.maxDate);
}
/** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
(reason) => !reason.gatedBy || this.data[reason.gatedBy]
);
}
/** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
* hidden in favour of a plain statement of that fact when this is true (see the template). */
get isAutoWaivedReason(): boolean {
return !!this.selectedReason?.autoWaived;
}
get selectedReasonLabel(): string {
return this.selectedReason?.label ?? '';
}
private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
}
private onClosureReasonChange(reasonId: number): void {
const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
if (reason?.autoWaived) {
// emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
// change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
// and the amount would be calculated twice for one reason change.
this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
}
this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
}
private calculatePrematureAmount(date: Date): void {
this.loadingAmount = true;
this.loadFailed = false;
@@ -118,7 +213,9 @@ export class PrematureCloseDialogComponent implements OnInit {
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
locale,
prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
@@ -127,6 +224,7 @@ export class PrematureCloseDialogComponent implements OnInit {
this.savingsAccountsData = response.savingsAccounts ?? [];
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
this.maturityAmount = response.maturityAmount;
this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
this.loadingAmount = false;
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
@@ -144,19 +242,50 @@ export class PrematureCloseDialogComponent implements OnInit {
});
}
/** Adds/removes the destination-account control to match what the chosen type needs. */
/** Adds/removes the destination control to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) {
if (!this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID;
const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID;
if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
} else if (!toSavings) {
this.prematureCloseForm.removeControl('toSavingsAccountId');
}
if (toGl) {
if (!this.prematureCloseForm.contains('toGlAccountId')) {
this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required));
}
this.loadGlAccounts();
} else {
this.prematureCloseForm.removeControl('toGlAccountId');
}
// Both destinations carry the description through -- to the account transfer, or to the narration on
// the journal entry that credits the GL account.
if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) {
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
} else if (!toSavings && !toGl) {
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
/** The GL selector takes a control instance, not a control name. */
get toGlAccountControl(): UntypedFormControl {
return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl;
}
/** The gap between the deposit and the computed maturity amount -- the actual interest this premature
* closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
get interestEarned(): number | null {
@@ -166,6 +295,94 @@ export class PrematureCloseDialogComponent implements OnInit {
return Number(this.maturityAmount) - Number(this.data.depositAmount);
}
get activatedOnDateDisplay(): string {
return this.formatFullDate(this.data.activatedOnDate);
}
get maturityDateDisplay(): string {
return this.formatFullDate(this.data.maturityDate);
}
/**
* The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
* Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
* clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
* advances it), and the backend's interest calculation is measured from THIS date, not today's actual
* date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
* interest the backend actually computed for the same closure -- both must read off the same clock.
*/
private get referenceDate(): Date {
const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
return picked instanceof Date ? picked : this.maxDate;
}
/** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
* closure date is somehow before it, rather than a negative. */
get daysElapsed(): number | null {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
}
/** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
* than negative, since this dialog is for a still-Active account closing early, not a matured one. */
get daysRemainingToMaturity(): number | null {
const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
if (!maturity) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
}
/** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
* count that is hard to read for a multi-year FD. */
get tenureElapsedDisplay(): string {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return '—';
}
const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} yr${years === 1 ? '' : 's'}`);
}
if (months > 0) {
parts.push(`${months} mo${months === 1 ? '' : 's'}`);
}
if (days > 0 || parts.length === 0) {
parts.push(`${days} day${days === 1 ? '' : 's'}`);
}
return parts.join(', ');
}
private formatFullDate(value: any): string {
if (!value) {
return '—';
}
const parsed = this.dateUtils.parseDate(value);
// "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
// account's own configured short date format elsewhere in the app.
return this.dateUtils.formatDate(parsed, 'd MMMM y');
}
private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
let years = end.getFullYear() - start.getFullYear();
let months = end.getMonth() - start.getMonth();
let days = end.getDate() - start.getDate();
if (days < 0) {
months -= 1;
const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
days += daysInPreviousMonth;
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
@@ -201,7 +418,18 @@ export class PrematureCloseDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
.subscribe({
next: () => {
next: (response: any) => {
// A command on a maker-checker permission is parked in the Checker Inbox, not executed:
// the platform rolls its transaction back and still answers HTTP 200 with
// rollbackTransaction: true. Without saying so, the dialog closes and the account reloads
// unchanged -- which reads as "nothing happened" rather than "waiting for approval".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -30,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
@@ -74,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
@@ -298,6 +300,18 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* The start date of the term currently running -- the last entry in renewalHistory, which is ordered
* oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
* an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
* in that case anyway.
*/
private get currentTermStartDate(): any {
const history = this.fixedDepositsAccountData?.renewalHistory;
const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
}
/**
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
* matured yet.
@@ -316,7 +330,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
// Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
// facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
// a product that never enabled them shouldn't offer a reason implying it did.
deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
// Tenure context (current term's start date, maturity date, current balance) so the dialog can
// show how far into THIS term the account is and how much is actually sitting in it today, not
// just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
// term's own start (see currentTermStartDate), not the account's original activation -- a
// renewed FD's interest/tenure runs from the renewal, and showing the original activation date
// here would make an account renewed days ago look like it has been running for months.
activatedOnDate: this.currentTermStartDate,
maturityDate: this.fixedDepositsAccountData.maturityDate,
currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
@@ -393,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
.subscribe(() => {
.subscribe((commandResponse: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with
// rollbackTransaction: true), so the reload below shows no new interest posting.
if (commandResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.'
});
}
this.reload();
});
}
@@ -41,8 +41,8 @@
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
@if (isTermActive(term, last)) {
<span class="rh-node__pulse"></span>
}
</span>
@@ -51,15 +51,15 @@
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card" [class.rh-card--active]="isTermActive(term, last)">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
<span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
<fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
{{ termStatusLabel(term, last) }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
@@ -95,7 +95,7 @@
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
@@ -106,9 +106,7 @@
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
@@ -183,6 +183,51 @@ export class RenewalHistoryTabComponent implements OnInit {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* Whether one term's card should show as "still running". `term.renewalstatus` only ever flips from
* ACTIVE to MATURED when a term rolls into its successor (see FixedDepositRenewalHistory#markMatured
* on the backend) -- a premature closure never touches that row, so the latest term's archived status
* would read ACTIVE forever even after the whole account has closed. For the LAST term only, this
* cross-checks the account's own live status instead of trusting the archived flag; every earlier
* term is reliably matured already (something renewed past it), so its own renewalstatus is
* trustworthy as-is and doesn't need the live account status at all.
*/
isTermActive(term: any, isLast: boolean): boolean {
if (isLast && this.fixedDepositsAccountData?.status) {
return !!this.fixedDepositsAccountData.status.active;
}
return term?.renewalstatus === 'ACTIVE';
}
/** Badge text for one term's card -- see {@link isTermActive} for why the last term needs its own check. */
termStatusLabel(term: any, isLast: boolean): string {
if (this.isTermActive(term, isLast)) {
return 'Active';
}
if (isLast && this.fixedDepositsAccountData?.status?.prematureClosed) {
return 'Premature Closed';
}
return 'Matured';
}
/**
* Label for the maturity/value figure -- "Projected Maturity" only makes sense while the term is
* still genuinely running towards that projection. `term.maturedamount` (the archived settled value)
* is only ever written when a term rolls into its successor, same gap as {@link isTermActive}: a
* premature closure settles the account without ever touching that column, so the last term would
* keep reading as a live projection forever even though the figure showing is already the final,
* settled one (see {@link maturityFor}).
*/
maturityLabel(term: any, isLast: boolean): string {
if (term?.maturedamount != null) {
return 'Matured At';
}
if (isLast && !this.isTermActive(term, isLast)) {
return 'Closed At';
}
return 'Projected Maturity';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -98,17 +100,31 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true).
// The account is therefore still pending, so chaining activation onto it would be applied to an
// unapproved account -- stop here and say what is actually waiting.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This approval has been sent for checker approval. The account stays pending until a checker approves it.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
return;
}
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -21,6 +21,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -43,6 +44,7 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -139,8 +141,18 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'close', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'close', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the deposit reloads unchanged -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Closure has been sent for checker approval. The deposit is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -97,8 +99,19 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'reject', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'reject', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Rejection has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -12,6 +12,7 @@ import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angu
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { AlertService } from 'app/core/alert/alert.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
@@ -35,6 +36,7 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Undo Approval Fixed Deposits Account form. */
undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
@@ -87,12 +89,30 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
if (this.undoAction === 'Undo Activation') {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe(() => {
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
} else {
this.savingsService.executeSavingsAccountCommand(this.accountId, this.undoCommand, data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.savingsService
.executeSavingsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
/**
* Both undo commands sit on maker-checker permissions, so either can come back parked in the
* Checker Inbox rather than executed -- HTTP 200 with rollbackTransaction: true, and the account
* reloaded unchanged. Without this the navigation away reads as "it worked".
*/
private notifyIfSentForApproval(response: any): void {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: `${this.undoAction} has been sent for checker approval. The account is unchanged until a checker approves it.`
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -99,7 +101,16 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data)
.subscribe(() => {
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Withdrawal has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -18,9 +18,16 @@
<p class="dialog-sub">NDTL &middot; CRR/SLR Computation Base &middot; As per RBI Master Circular</p>
</div>
<div class="header-right">
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
because they are not the same day and the requirement belongs to the earlier one. -->
<div class="as-of-box">
<strong>{{ reportingDateStr }}</strong>
<span>Reporting Fortnight (45-day lag)</span>
<strong>{{ reportingDateStr || '—' }}</strong>
<span>
Fortnight close
@if (baseDateStr) {
&middot; charged on {{ baseDateStr }}
}
</span>
</div>
<a
class="rbi-circular-btn"
@@ -42,31 +49,42 @@
<div class="summary-row">
<div class="summary-card sc-demand">
<div class="sc-label">Total Demand Liabilities</div>
<div class="sc-value">&#8377;12,843<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 3.2% vs prev fortnight</div>
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
<div class="sc-sub">Payable on demand</div>
</div>
<div class="summary-card sc-time">
<div class="sc-label">Total Time Liabilities</div>
<div class="sc-value">&#8377;64,618<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 1.8% vs prev fortnight</div>
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
<div class="sc-sub">Payable on maturity</div>
</div>
<div class="summary-card sc-asset">
<div class="sc-label">Assets with Banking System</div>
<div class="sc-value">&#8377;8,240<span class="cr">Cr</span></div>
<div class="sc-value">
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
</div>
<div class="sc-change dn">&#8595; Deducted from NDTL</div>
</div>
<div class="summary-card sc-ndtl">
<div class="sc-label">Net Demand &amp; Time Liabilities (NDTL)</div>
<div class="sc-value">&#8377;69,221<span class="cr">Cr</span></div>
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
<div class="sc-sub">After deducting banking assets</div>
</div>
<div class="summary-card sc-crr">
<div class="sc-label">CRR Required ({{ crrRate }}%)</div>
<div class="sc-value">&#8377;{{ crrRequired | number: '1.0-0' }}<span class="cr">Cr</span></div>
<div class="sc-sub">SLR Req: &#8377;{{ slrRequired | number: '1.0-0' }} Cr ({{ slrRate }}%)</div>
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
(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-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
</div>
</div>
<!-- Why the figures are not there, in the return's own words. A dashboard that simply shows
dashes sends an administrator looking for a fault in the dashboard. -->
<div class="unavailable-note" *ngIf="unavailable">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ unavailable }}
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn" [class.active]="activeTab === 'demand'" (click)="activeTab = 'demand'">
@@ -123,14 +141,23 @@
</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Demand Liabilities (All 16 Items)</strong></td>
<td class="right foot-demand">12,114.75</td>
<td class="right foot-demand">12,843.50</td>
<td class="right up">+728.75</td>
<td colspan="3">
<strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong>
</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></td>
</tr>
</tfoot>
@@ -178,14 +205,23 @@
</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Time Liabilities (All 13 Items)</strong></td>
<td class="right foot-time">62,613.10</td>
<td class="right foot-time">64,618.00</td>
<td class="right up">+2,004.90</td>
<td colspan="3">
<strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong>
</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></td>
</tr>
</tfoot>
@@ -227,6 +263,13 @@
<span class="status-chip chip-active">{{ item.status }}</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
@@ -234,9 +277,9 @@
<td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
</td>
<td class="right foot-asset">7,600.70</td>
<td class="right foot-asset">8,240.20</td>
<td class="right up">+639.50</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(bankingAssets) }}</td>
<td></td>
</tr>
</tfoot>
@@ -284,14 +327,21 @@
<span class="status-chip chip-active">{{ item.status }}</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td>
<td class="right foot-slr">12,511.70</td>
<td class="right foot-slr">13,238.80</td>
<td class="right up">+727.10</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, 'openingValue') }}</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(liquidAssets) }}</td>
<td></td>
</tr>
</tfoot>
@@ -842,3 +842,30 @@ $slr-b: #bae6fd;
padding: 8px 16px;
}
}
/* Why the figures are missing, in the return's own words — an administrator reading only dashes
goes looking for a fault in this screen rather than in the configuration that actually caused it. */
.unavailable-note {
margin: 0 0 16px;
padding: 10px 14px;
background: #fffbeb;
color: #92400e;
border-radius: 8px;
font-size: 12.5px;
line-height: 1.6;
fa-icon {
margin-right: 8px;
}
}
/* A head with nothing mapped. Said in the table rather than left as an empty body, and it names the
screen where the mapping is made — an empty table otherwise reads as a fault in this one. */
.empty-row td {
padding: 22px 16px;
text-align: center;
color: #6b7280;
font-size: 12.5px;
line-height: 1.6;
background: #fafafa;
}
@@ -6,13 +6,30 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
/** One account under a head, at the base date and the reporting date. */
interface CrrSlrRow {
sl: string;
name: string;
sub: string;
code: string;
opening: string;
current: string;
/** The figures themselves, kept so totals add from values rather than from formatted text. */
openingValue: number | null;
currentValue: number | null;
change: string;
changeDir: 'up' | 'dn' | 'flat';
status: string;
}
@Component({
selector: 'mifosx-crr-slr-dialog',
@@ -29,477 +46,228 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
FaIconComponent
]
})
export class CrrSlrDialogComponent {
export class CrrSlrDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
private rbi = inject(RbiCircularsService);
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
/** Reporting fortnight date — 45 days before current date */
today = new Date();
reportingDate: Date;
reportingDateStr: string;
/** The date the figures were struck at, as the return itself reports it. */
reportingDateStr = '';
/** NDTL & Reserve computations */
readonly totalDemand = 12843.5;
readonly totalTime = 64618.0;
readonly totalBankingAssets = 8240.2;
readonly ndtl = 69221.3; // Demand + Time - Banking Assets
readonly crrRate = 3; // Current RBI CRR rate (%)
readonly slrRate = 18; // Current RBI SLR rate (%)
readonly crrRequired = 69221.3 * 0.03; // ₹2,076.64 Cr
readonly slrRequired = 69221.3 * 0.18; // ₹12,459.83 Cr
readonly totalLiquidAssets = 13238.8;
readonly slrActualPercent = +((13238.8 / 69221.3) * 100).toFixed(2); // 19.12%
readonly slrSurplus = +(13238.8 - 69221.3 * 0.18).toFixed(2); // 778.97
readonly totalReserve = +(69221.3 * 0.03 + 69221.3 * 0.18).toFixed(2); // 14,536.47
/**
* The close the ratios are charged on — the second preceding fortnight, not the one reported.
*
* Named on screen because the gap between the two is the whole of how the rules work: a bank
* cannot hold a reserve against liabilities it does not yet know, so the requirement is fixed on
* a position already struck and reported. A screen showing one date invites a reader to think the
* requirement was worked out on the day it is shown against.
*/
baseDateStr = '';
constructor() {
// CRR/SLR uses data from 45 days ago
const lagDays = 45;
this.reportingDate = new Date(this.today);
this.reportingDate.setDate(this.reportingDate.getDate() - lagDays);
this.reportingDateStr = this.reportingDate.toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
loading = true;
/** Set when the return could not be computed — and why, in the return's own words. */
unavailable = '';
/**
* The NDTL computation, read from the return rather than stated here.
*
* These were fixed numbers until now — a demonstration screen that showed ₹69,221 Cr whatever the
* ledger held. The figures are the bank's own regulatory position, so a plausible-looking number
* is worse than no number: it reads as a reported figure and cannot be told apart from one.
*
* Null until the return answers, and left null where it cannot compute. The template shows a dash
* for a null, which says "not reported" where a zero would say "reported as nil".
*/
totalDemand: number | null = null;
totalTime: number | null = null;
totalBankingAssets: number | null = null;
ndtl: number | null = null;
crrRate: number | null = null;
slrRate: number | null = null;
crrRequired: number | null = null;
slrRequired: number | null = null;
totalLiquidAssets: number | null = null;
slrActualPercent: number | null = null;
slrSurplus: number | null = null;
totalReserve: number | null = null;
/**
* A figure for display, or a dash where the return did not carry one.
*
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
*/
crore(value: number | null): string {
if (value === null) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { maximumFractionDigits: 0 })}`;
}
ngOnInit(): void {
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
const toCrore = (v: number | null | undefined): number | null =>
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
// at the month end — and this screen should show the close the bank last filed, not a position
// struck 45 days back by a lag rule that belongs to the other report. Those two disagree by
// weeks, and the 45-day one lands mid-fortnight on a date the bank never reported.
this.rbi.getForm1Statutory().subscribe({
next: (data: any) => {
this.loading = false;
// The later of the two closes the return reports — the one the bank most recently filed.
const columns: string[] = data?.columns ?? [];
const latest = columns.length - 1;
this.reportingDateStr = columns[latest] ?? '';
this.baseDateStr = data?.baseColumns?.[latest] ?? '';
this.crrRate = data?.crrPercentage ?? null;
this.slrRate = data?.slrPercentage ?? null;
if (!columns.length) {
this.unavailable =
data?.unmetRequirements?.[0] ?? 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
return;
}
// The return computes but says something would make it wrong to file — a rate never set, a
// base date the ledger cannot answer. Shown rather than swallowed: the reason is the thing
// an administrator needs in order to fix it.
this.unavailable = data?.unmetRequirements?.[0] ?? '';
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
// than recomputed, so this screen and the filed form cannot come to disagree.
const lines: any[] = data?.partA?.lines ?? [];
const at = (startsWith: string): number | null => {
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
return line?.values?.[latest] ?? null;
};
this.totalDemand = toCrore(at('(A) Demand liabilities'));
this.totalTime = toCrore(at('(B) Time liabilities'));
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
// not the bank's liabilities on the day being reported.
this.ndtl = toCrore(at('IV. b)'));
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
}
if (this.ndtl) {
this.slrActualPercent =
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
}
if (this.crrRequired !== null && this.slrRequired !== null) {
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
}
this.fillTabs(lines, latest);
},
error: () => {
this.loading = false;
this.unavailable = 'The CRR/SLR position could not be read. The return service did not respond.';
}
});
}
demandLiabilities = [
{
sl: '01',
name: 'Current Account Deposits',
sub: 'Corporate & individual CA balances',
code: '2001-CA',
opening: '3,842.50',
current: '4,120.75',
change: '+278.25',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Savings Bank Deposits (Withdrawable)',
sub: 'Demand portion — 10% of SB balance',
code: '2002-SB',
opening: '1,240.00',
current: '1,318.40',
change: '+78.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Demand Drafts Payable',
sub: 'Issued DDs outstanding for payment',
code: '2003-DD',
opening: '284.20',
current: '311.60',
change: '+27.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: "Banker's Cheques / Pay Orders",
sub: 'Issued pay orders pending encashment',
code: '2004-PO',
opening: '198.75',
current: '176.30',
change: '-22.45',
changeDir: 'dn',
status: 'Active'
},
{
sl: '05',
name: 'Credit Balance in Cash Credit Accounts',
sub: 'CC accounts in credit position',
code: '2005-CC',
opening: '542.10',
current: '618.90',
change: '+76.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '06',
name: 'Overdue Fixed Deposits',
sub: 'FDs past maturity — treated as demand',
code: '2006-OFD',
opening: '89.40',
current: '112.85',
change: '+23.45',
changeDir: 'dn',
status: 'Overdue'
},
{
sl: '07',
name: 'Interest Payable on Demand Deposits',
sub: 'Accrued interest on CA/SB',
code: '2007-INT',
opening: '34.20',
current: '38.55',
change: '+4.35',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Margins held against Letters of Credit',
sub: 'Withdrawable LC margin deposits',
code: '2008-LCM',
opening: '412.60',
current: '388.20',
change: '-24.40',
changeDir: 'dn',
status: 'Active'
},
{
sl: '09',
name: 'Margins held against Bank Guarantees',
sub: 'Withdrawable BG margin deposits',
code: '2009-BGM',
opening: '318.40',
current: '342.10',
change: '+23.70',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Security Deposits (Withdrawable)',
sub: 'EMD / SD refundable on demand',
code: '2010-SD',
opening: '124.80',
current: '118.60',
change: '-6.20',
changeDir: 'dn',
status: 'Active'
},
{
sl: '11',
name: 'Unclaimed Deposits (Demand Portion)',
sub: 'Balances unclaimed >10 years',
code: '2011-UCD',
opening: '56.30',
current: '59.40',
change: '+3.10',
changeDir: 'dn',
status: 'Unclaimed'
},
{
sl: '12',
name: 'Inter-Bank Demand Deposits',
sub: 'Demand deposits from other banks',
code: '2012-IBD',
opening: '2,840.00',
current: '3,120.50',
change: '+280.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Clearing Adjustments / Balances',
sub: 'Net clearing settlement balances',
code: '2013-CLR',
opening: '186.40',
current: '210.80',
change: '+24.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '14',
name: 'Outstanding Payment Instruments',
sub: 'Cheques issued but not yet presented',
code: '2014-OPI',
opening: '142.60',
current: '158.90',
change: '+16.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '15',
name: 'Demand Liabilities towards Other Banks',
sub: 'Nostro / Vostro demand obligations',
code: '2015-DLB',
opening: '984.20',
current: '1,048.60',
change: '+64.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '16',
name: 'Deposits Repayable on Demand',
sub: 'Miscellaneous on-demand deposits',
code: '2016-DRD',
opening: '618.30',
current: '698.05',
change: '+79.75',
changeDir: 'up',
status: 'Active'
/**
* The four tabs, filled from the return's own lines.
*
* These were four fixed lists until now — 435 lines of invented accounts with invented balances,
* which a reader had no way to tell apart from the bank's real position. They are now built from
* the accounts the return actually read, so a tab is empty exactly when the bank has nothing
* mapped under that head, and says so.
*/
demandLiabilities: CrrSlrRow[] = [];
timeLiabilities: CrrSlrRow[] = [];
bankingAssets: CrrSlrRow[] = [];
liquidAssets: CrrSlrRow[] = [];
/**
* Turns Part A's lines into the rows a tab shows.
*
* The form reports each head at both of the closes it carries, so the earlier column is the
* opening and the later one the current — the same pair these tables have always shown, now taken
* from the form rather than invented. Headings and totals are skipped: a heading carries no
* figure, and a total would be added a second time by the footer below.
*/
private rowsFrom(lines: any[], latest: number, match: (label: string) => boolean): CrrSlrRow[] {
const rows: CrrSlrRow[] = [];
for (const line of lines ?? []) {
const label = String(line?.label ?? '');
if (line?.heading || line?.total || !line?.values || !match(label)) {
continue;
}
const opening = latest > 0 ? (line.values[latest - 1] ?? null) : null;
const current = line.values[latest] ?? null;
const change = opening === null || current === null ? null : current - opening;
rows.push({
sl: String(rows.length + 1).padStart(2, '0'),
// The form's own wording, with its numbering trimmed off the front — the table has a column
// of its own for that.
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
code: line.configKey ?? '—',
opening: this.crore(opening),
current: this.crore(current),
openingValue: opening,
currentValue: current,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
status: line.configured === false ? 'Not mapped' : 'Active'
});
}
];
return rows;
}
timeLiabilities = [
{
sl: '01',
name: 'Fixed Deposits (FD)',
sub: '7 days to 10 years · 6.5–7.2%',
code: '3001-FD',
opening: '28,420.50',
current: '29,184.30',
change: '+763.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Recurring Deposits (RD)',
sub: 'Monthly instalment · 6.0–6.8%',
code: '3002-RD',
opening: '4,218.60',
current: '4,412.80',
change: '+194.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Time Liability Portion of Savings',
sub: '90% of SB balance treated as time',
code: '3003-SBT',
opening: '10,840.20',
current: '11,204.60',
change: '+364.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: 'Reinvestment Deposits',
sub: 'Cumulative interest plans · 7.0%',
code: '3004-RID',
opening: '1,842.40',
current: '1,918.70',
change: '+76.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '05',
name: 'Staff Security Deposits (Fixed Period)',
sub: 'Employee security deposits — locked',
code: '3005-SSD',
opening: '84.60',
current: '86.40',
change: '+1.80',
changeDir: 'up',
status: 'Locked'
},
{
sl: '06',
name: 'Margin Money for Guarantees / LC',
sub: 'LC/BG margins — fixed lock-in period',
code: '3006-MGM',
opening: '620.80',
current: '644.20',
change: '+23.40',
changeDir: 'up',
status: 'Locked'
},
{
sl: '07',
name: 'Certificates of Deposit (CD)',
sub: 'Issued to corporates / FIs · 7.35%',
code: '3007-CD',
opening: '3,840.00',
current: '3,920.50',
change: '+80.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Interest Accrued on Time Deposits',
sub: 'Accrued but unpaid interest on FD/RD',
code: '3008-IAT',
opening: '842.60',
current: '918.40',
change: '+75.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '09',
name: 'Long-term Deposits',
sub: 'Tenor >5 years · 7.5%',
code: '3009-LTD',
opening: '5,620.40',
current: '5,818.60',
change: '+198.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Tax Saving Deposits',
sub: 'Sec 80C FDs — 5-year lock-in · 6.8%',
code: '3010-TSD',
opening: '1,240.80',
current: '1,318.40',
change: '+77.60',
changeDir: 'up',
status: 'Locked'
},
{
sl: '11',
name: 'Foreign Currency Deposits',
sub: 'FCNR(B), RFC — USD/EUR/GBP',
code: '3011-FCD',
opening: '2,184.60',
current: '2,348.20',
change: '+163.60',
changeDir: 'up',
status: 'Active'
},
{
sl: '12',
name: 'Time Liabilities to Other Banks',
sub: 'Inter-bank term deposits — call money',
code: '3012-TLB',
opening: '1,840.20',
current: '1,924.40',
change: '+84.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Term Borrowings',
sub: 'Bonds, debentures, refinance · 8.1%',
code: '3013-TBR',
opening: '1,018.40',
current: '1,118.50',
change: '+100.10',
changeDir: 'up',
status: 'Active'
}
];
/**
* A tab's column total, added from the rows on screen.
*
* Added from the rows rather than stated separately, so the foot of a table always agrees with
* what is above it. The footers used to be fixed text and agreed with nothing.
*
* Summed from each row's own figure, not from the text it displays: a total parsed back out of
* formatted strings would quietly lose the paise the display rounds away.
*/
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
}
bankingAssets = [
{
sl: '01',
name: 'Balances with Other Banks',
sub: 'Nostro / Vostro balances — SBI, PNB, BOB',
code: '4001-BWB',
opening: '3,840.20',
current: '4,112.40',
change: '+272.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Money at Call with Banks',
sub: 'Overnight & short-notice call money',
code: '4002-MCB',
opening: '2,180.50',
current: '2,418.60',
change: '+238.10',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Loans to Banks (Inter-Bank Lending)',
sub: 'Term loans & credit lines to SCBs',
code: '4003-LTB',
opening: '1,580.00',
current: '1,709.20',
change: '+129.20',
changeDir: 'up',
status: 'Active'
/** The movement across a tab, between the two closes the form reports. */
totalChange(rows: CrrSlrRow[]): string {
const opening = rows.map((r) => r.openingValue).filter((v): v is number => v !== null);
const current = rows.map((r) => r.currentValue).filter((v): v is number => v !== null);
if (!opening.length || !current.length) {
return '—';
}
];
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
}
liquidAssets = [
{
sl: '01',
name: 'Central Government Securities',
sub: 'G-Secs held in HTM, AFS & HFT portfolios',
code: '5001-CGS',
opening: '6,840.50',
current: '7,218.40',
change: '+377.90',
changeDir: 'up',
status: 'Active',
category: 'G-Sec'
},
{
sl: '02',
name: 'State Development Loans (SDLs)',
sub: 'State govt bonds — various maturities',
code: '5002-SDL',
opening: '2,420.30',
current: '2,584.60',
change: '+164.30',
changeDir: 'up',
status: 'Active',
category: 'SDL'
},
{
sl: '03',
name: 'Treasury Bills (91/182/364 day)',
sub: 'Short-term govt securities — discounted',
code: '5003-TB',
opening: '1,840.20',
current: '1,962.80',
change: '+122.60',
changeDir: 'up',
status: 'Active',
category: 'T-Bill'
},
{
sl: '04',
name: 'Gold (valued at current market)',
sub: 'Gold reserves held per RBI valuation norms',
code: '5004-GLD',
opening: '680.40',
current: '724.60',
change: '+44.20',
changeDir: 'up',
status: 'Active',
category: 'Gold'
},
{
sl: '05',
name: 'Special Deposit Scheme with RBI',
sub: 'Deposits placed under SDS — SLR eligible',
code: '5005-SDS',
opening: '420.00',
current: '420.00',
change: '0.00',
changeDir: 'up',
status: 'Active',
category: 'RBI Dep'
},
{
sl: '06',
name: 'NABARD / SIDBI / NHB Bonds',
sub: 'PSE bonds approved as SLR-eligible',
code: '5006-PSE',
opening: '310.80',
current: '328.40',
change: '+17.60',
changeDir: 'up',
status: 'Active',
category: 'PSE Bond'
}
];
/** Fills the four tabs from one reading of the form. */
private fillTabs(lines: any[], latest: number): void {
const has = (label: string, ...words: string[]): boolean => {
const l = label.toLowerCase();
return words.some((w) => l.includes(w));
};
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
// maps a new account to a head gets it on the right tab without this screen being changed.
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
// III — what the banking system owes this bank, which nets off the liability base.
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
);
// V, VI and VII — the cash, balances and securities the two ratios are met in.
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
);
}
close(): void {
this.dialogRef.close();
@@ -28,17 +28,34 @@
<fa-icon icon="external-link-alt"></fa-icon> View
</button>
</div>
<!-- Read from the return, not stated here. Both the rates and the amounts were fixed text
until now, so the card reported ₹2,077 Cr at 3% whatever the ledger held and whatever
rate Admin was on. -->
<div class="card-metrics">
<div class="metric-row">
<span class="metric-label">CRR Required (3%)</span>
<span class="metric-value">&#8377;2,077 Cr</span>
<span class="metric-label"
>CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div>
<div class="metric-row">
<span class="metric-label">SLR Required (18%)</span>
<span class="metric-value">&#8377;12,460 Cr</span>
<span class="metric-label"
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div>
</div>
<div class="mini-sub"><span class="tag-success">Within Limits</span></div>
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
says so. Where it cannot compute, the card says that instead of passing the bank. -->
<div class="mini-sub">
@if (crrSlr.status === 'ok') {
<span class="tag-success">Within Limits</span>
} @else if (crrSlr.status === 'short') {
<span class="tag-danger">Shortfall</span>
} @else {
<span class="tag-muted">Not computed</span>
}
</div>
</div>
<!-- CRAR -->
@@ -444,6 +444,17 @@ $purple: #7c3aed;
font-weight: 600;
}
/* A figure that could not be computed. Deliberately not green: a tile that cannot read the ledger
has not found the bank compliant, and a success tag on an unknown position is a false all-clear. */
.tag-muted {
background: #f3f4f6;
color: #4b5563;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
/* ─── Compact Detail Cards ─── */
.dash-card.compact {
padding: 12px 16px;
@@ -19,6 +19,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { AsyncPipe, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiCircularsService } from '../../rbi-circulars/rbi-circulars.service';
/**
* Dashboard component.
*/
@@ -40,9 +41,29 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class DashboardComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
private rbi = inject(RbiCircularsService);
today = new Date();
/**
* The CRR/SLR tile, read from the return.
*
* The amounts and the rates were both fixed text on the template until now, so the tile reported
* ₹2,077 Cr at 3% whatever the ledger held and whatever rate Admin was on. A dashboard figure is
* read as the bank's position, and one that cannot move is worse than none: nothing on the tile
* says it is a placeholder.
*
* Dashes until the return answers. 'status' drives the compliance tag, which is only claimed
* where the return states it.
*/
crrSlr: {
crrRate: number | null;
slrRate: number | null;
crrRequired: string;
slrRequired: string;
status: 'ok' | 'short' | 'unknown';
} = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
/** Array of all user activities */
userActivity: string[];
/** Array of most recent user activities */
@@ -67,6 +88,45 @@ export class DashboardComponent implements OnInit {
this.recentActivities = this.getRecentActivities();
this.frequentActivities = this.getFrequentActivities();
this.setFilteredActivities();
this.loadCrrSlr();
}
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
private loadCrrSlr(): void {
const crore = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
// reported, so a tile built on it shows a position that appears on no filing.
this.rbi.getForm1Statutory().subscribe({
next: (data: any) => {
const latest = (data?.columns?.length ?? 0) - 1;
if (latest < 0) {
return;
}
this.crrSlr.crrRate = data?.crrPercentage ?? null;
this.crrSlr.slrRate = data?.slrPercentage ?? null;
this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
// bank short on SLR is not within limits because its cash reserve happens to be ample.
const crrHeld = data?.partB?.maintained?.[latest];
const crrNeed = data?.partB?.required?.[latest];
const slrHeld = data?.partC?.maintained?.[latest];
const slrNeed = data?.partC?.required?.[latest];
if ([
crrHeld,
crrNeed,
slrHeld,
slrNeed
].every((v) => v !== null && v !== undefined)) {
this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short';
}
},
error: () => undefined
});
}
/**
@@ -0,0 +1,322 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Reports</h2>
<div class="ipr-head-s">
Each branch gets its own report for each savings product, generated automatically at 12:00 PM on the posting
date and open to generating or regenerating at any time. The branch approves it, and its approved interest is
posted when the branch closes the day — it cannot close until then. The head office closes last.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
Posting date
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon> Preview interest (today)
</button>
</div>
</div>
@if (isHeadOffice) {
<div class="ipr-tabs">
<button class="ipr-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon> My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="ipr-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon> Other branches
</button>
</div>
@if (tab === 'others') {
<div class="ipr-alert ipr-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Each branch generates and approves its own reports. The head office can act here only for a branch that has
not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
</div>
</div>
}
}
@if (showPreview) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Interest preview — earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each account would be paid if interest were posted today. View only — nothing is saved, approved or
posted.
</div>
</div>
<div class="ipr-total">
<span>Total so far</span>
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} account(s)</span>
</div>
</div>
<div class="ipr-preview-actions">
<button class="ipr-btn ipr-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon> Refresh
</button>
<button
class="ipr-btn ipr-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
<button class="ipr-btn ipr-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon> Close</button>
</div>
@if (previewLoading) {
<div class="ipr-empty">Calculating interest up to today…</div>
} @else {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (g of previewGroups; track g.officeName + g.productName) {
<tr>
<td>{{ g.officeName }}</td>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>{{ g.postingDate }}</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">₹{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="ipr-empty">No savings account has interest accrued in its current cycle.</td>
</tr>
}
</tbody>
</table>
@if (previewRows.length > 0) {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Balance</th>
<th class="num">Rate %</th>
<th>Cycle</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.savingsAccountId) {
<tr>
<td>{{ r.officeName }}</td>
<td>{{ r.productName }}</td>
<td>{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td class="num">₹{{ r.balance | number: '1.2-2' }}</td>
<td class="num">{{ r.rate | number: '1.2-2' }}</td>
<td class="muted">{{ r.cycleStart }} → {{ r.postingDate }}</td>
<td class="num">₹{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
}
</div>
}
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (notice) {
<div class="ipr-alert ipr-alert--ok">
<fa-icon icon="check"></fa-icon>
<div>{{ notice }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are approved.</div>
</div>
}
@if (due.length > 0) {
<div class="ipr-alert ipr-alert--warn ipr-due">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div class="ipr-due-body">
<div>Interest is due but no report has been generated yet for:</div>
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="ipr-due-row">
<strong>{{ d.productName }}</strong> <span>· {{ d.officeName }} · {{ d.postingDate }}</span>
@if (d.canAct) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> {{ tab === 'others' ? 'Generate on behalf' : 'Generate report' }}
</button>
} @else if (d.blockedReason) {
<span class="ipr-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
</div>
}
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Total interest</th>
<th>Status</th>
<th>Generated</th>
<th>Approved</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of reports; track r.id) {
<tr [class.is-selected]="selected?.id === r.id" (click)="open(r)">
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td>{{ r.postingDate }}</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
@if (r.failureReason) {
<div class="ipr-fail" [title]="r.failureReason">Posting failed: {{ r.failureReason }}</div>
}
</td>
<td class="muted">
{{ r.generatedBy || 'System' }} · {{ r.generationMode }}<br />{{ r.generatedAt | date: 'dd MMM, HH:mm' }}
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="actions" (click)="$event.stopPropagation()">
@if (canRegenerate(r)) {
<button class="ipr-btn ipr-btn--slim" (click)="regenerate(r)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> Regenerate
</button>
}
@if (canApprove(r)) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon> {{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
}
@if (!r.canAct && r.blockedReason && (r.status === 'GENERATED' || r.status === 'APPROVED')) {
<span class="ipr-blocked">{{ r.blockedReason }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report for {{ postingDate || 'this date' }}. Reports are generated at 12:00 PM on a
product's posting date.
}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (selected) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>{{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }}</h3>
<div class="muted">
Dr {{ selected.expenseGlCode }} {{ selected.expenseGlName }} → Cr {{ selected.payableGlCode }}
{{ selected.payableGlName }}; Dr {{ selected.payableGlCode }} → Cr {{ selected.paidGlCode }}
{{ selected.paidGlName }}; then Dr {{ selected.paidGlCode }} → Cr each account below
</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ selected.accountCount }} account(s)</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Interest</th>
<th class="num">Balance before posting</th>
<th class="num">Balance after posting</th>
<th>Posted txn</th>
</tr>
</thead>
<tbody>
@for (l of selected.lines; track l.savingsAccountId) {
<tr>
<td>{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="num">₹{{ l.interestAmount | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceBefore | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceAfter | number: '1.2-2' }}</td>
<td class="muted">{{ l.savingsTransactionId ? '#' + l.savingsTransactionId : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,327 @@
/**
* 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 {
--i-navy: #1a237e;
--i-blue: #1d4ed8;
--i-blue-l: #eff6ff;
--i-green: #15803d;
--i-green-l: #dcfce7;
--i-amber: #b45309;
--i-amber-l: #fef3c7;
--i-red: #c0392b;
--i-red-l: #fdecea;
--i-g100: #f1f5f9;
--i-g200: #e2e8f0;
--i-g500: #64748b;
--i-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--i-g900);
}
.ipr {
max-width: 1280px;
margin: 0 auto;
padding: 16px;
}
.ipr-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-bottom: 14px;
}
.ipr-head-t {
margin: 0;
font-size: 22px;
color: var(--i-navy);
}
.ipr-head-s {
max-width: 720px;
margin-top: 4px;
font-size: 13px;
color: var(--i-g500);
}
.ipr-head-a {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ipr-date {
display: flex;
flex-direction: column;
font-size: 11px;
color: var(--i-g500);
input,
select {
padding: 6px 8px;
border: 1px solid var(--i-g200);
border-radius: 6px;
font: inherit;
font-size: 13px;
}
}
.ipr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--i-g200);
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 13px;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: default;
}
}
.ipr-btn--primary {
border-color: var(--i-blue);
background: var(--i-blue);
color: #fff;
}
.ipr-btn--slim {
padding: 4px 10px;
font-size: 12px;
}
.ipr-alert {
display: flex;
gap: 8px;
margin-bottom: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.ipr-alert--error {
background: var(--i-red-l);
color: var(--i-red);
}
.ipr-alert--ok {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-alert--warn {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-card {
margin-bottom: 14px;
border: 1px solid var(--i-g200);
border-radius: 10px;
background: #fff;
overflow-x: auto;
}
.ipr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 9px 12px;
border-bottom: 1px solid var(--i-g100);
text-align: left;
vertical-align: top;
}
th {
background: var(--i-g100);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--i-g500);
}
tbody tr {
cursor: pointer;
}
tbody tr.is-selected {
background: var(--i-blue-l);
}
.num {
text-align: right;
white-space: nowrap;
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
cursor: default;
}
tfoot td {
background: var(--i-g100);
}
}
.muted {
color: var(--i-g500);
font-size: 12px;
}
.ipr-empty {
padding: 24px !important;
text-align: center !important;
color: var(--i-g500);
}
.ipr-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.ipr-badge--pending {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-badge--approved {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-badge--posted {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-badge--muted {
background: var(--i-g100);
color: var(--i-g500);
}
.ipr-fail {
margin-top: 4px;
font-size: 11px;
color: var(--i-red);
}
.ipr-detail-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
h3 {
margin: 0 0 4px;
font-size: 16px;
color: var(--i-navy);
}
}
.ipr-total {
display: flex;
flex-direction: column;
align-items: flex-end;
strong {
font-size: 22px;
color: var(--i-navy);
}
}
.ipr-due-body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.ipr-due-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
// Approved by the head office on behalf of a branch that did not open that day.
.ipr-behalf {
margin-left: 4px;
font-size: 11px;
font-weight: 600;
color: var(--i-amber);
}
// Preview: refresh / export / close, above the totals.
.ipr-preview-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
padding: 0 16px 10px;
}
.ipr-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
border-bottom: 1px solid var(--i-g200);
}
.ipr-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
margin-bottom: -1px;
font-size: 13px;
font-weight: 600;
color: var(--i-g500);
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 6px 6px 0 0;
&.is-active {
color: var(--i-navy);
background: #fff;
border-color: var(--i-g200);
border-bottom-color: #fff;
}
}
.ipr-alert--info {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-blocked {
display: inline-block;
max-width: 320px;
font-size: 11px;
line-height: 1.35;
color: var(--i-g500);
white-space: normal;
}
@@ -0,0 +1,398 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingDue,
InterestPostingReport,
InterestPostingReportDetail,
InterestPostingReportService,
InterestPreviewRow,
ReportScope
} from './interest-posting-report.service';
/**
* Savings interest posting reports.
*
* On a product's posting date the system generates, at noon, the list of what each savings account
* will be paid. It is reviewed and approved here; the approved amounts are posted when the last
* branch closes the day, and until then no branch can close. Regenerating replaces the report and
* needs approving again.
*/
@Component({
selector: 'mifosx-interest-posting-report',
templateUrl: './interest-posting-report.component.html',
styleUrls: ['./interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingReportComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
postingDate = '';
loading = false;
busy = false;
errorMessage = '';
notice = '';
reports: InterestPostingReport[] = [];
selected: InterestPostingReportDetail | null = null;
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: InterestPostingDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: ReportScope = 'own';
isHeadOffice = false;
myBranch = '';
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: ReportScope): void {
if (this.tab === tab) {
return;
}
this.tab = tab;
this.selected = null;
this.notice = '';
this.load();
}
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
private confirmOnBehalf(officeName: string, action: string): boolean {
if (this.tab !== 'others') {
return true;
}
return confirm(
`${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` +
'from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
if (stillThere) {
this.open(stillThere);
} else {
this.selected = null;
}
},
error: (err) => {
this.loading = false;
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: InterestPostingDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
}
});
}
open(report: InterestPostingReport): void {
this.errorMessage = '';
this.reportService.detail(report.id).subscribe({
next: (detail) => (this.selected = detail),
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
});
}
regenerate(report: InterestPostingReport): void {
if (
!confirm(
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report regenerated for ${report.productName}. Review and approve it.`;
this.selected = null;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
}
});
}
approve(report: InterestPostingReport): void {
if (
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
'It will be posted when the branch closes the day.'
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.approve(report.id).subscribe({
next: () => {
this.busy = false;
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
this.load();
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
}
});
}
canRegenerate(report: InterestPostingReport): boolean {
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
}
canApprove(report: InterestPostingReport): boolean {
return report.canAct && report.status === 'GENERATED';
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted',
SUPERSEDED: 'ipr-badge--muted'
}[status] ?? 'ipr-badge--muted'
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Preview: what each account has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: InterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso();
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the interest preview.');
}
});
}
closePreview(): void {
this.showPreview = false;
}
get previewTotal(): number {
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
}
/** Totals by branch and product, for the summary above the account list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
postingDate: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; postingDate: string; accounts: number; interest: number }
>();
this.previewRows.forEach((r) => {
const key = `${r.officeName}|${r.productName}`;
const g = map.get(key) ?? {
officeName: r.officeName,
productName: r.productName,
postingDate: r.postingDate,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By account',
title: 'Savings Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Account No',
'Customer',
'Balance',
'Rate %',
'Cycle Start',
'Posting Date',
'Interest So Far'
],
widths: [
18,
22,
20,
26,
16,
8,
12,
12,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
r.accountNo,
r.clientName,
Number(r.balance || 0),
Number(r.rate || 0),
r.cycleStart,
r.postingDate,
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Savings Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Posting Date',
'Accounts',
'Interest So Far'
],
widths: [
18,
22,
12,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
g.postingDate,
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`savings-interest-preview-${this.previewAsOf}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
private messageOf(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
);
}
}
@@ -0,0 +1,180 @@
/**
* 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 { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type ReportScope = 'own' | 'others';
export interface InterestPostingReport {
id: number;
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
status: InterestPostingReportStatus;
totalInterest: number;
accountCount: number;
generationMode: 'AUTO' | 'MANUAL';
generatedAt: string;
generatedBy: string | null;
approvedAt: string | null;
approvedBy: string | null;
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
/** Why not, where canAct is false. */
blockedReason: string | null;
}
export interface InterestPostingReportLine {
savingsAccountId: number;
accountNo: string;
clientName: string;
interestAmount: number;
balanceBefore: number;
balanceAfter: number;
savingsTransactionId: number | null;
}
export interface InterestPostingReportDetail extends InterestPostingReport {
payableGlCode: string | null;
payableGlName: string | null;
paidGlCode: string | null;
paidGlName: string | null;
expenseGlCode: string | null;
expenseGlName: string | null;
lines: InterestPostingReportLine[];
}
export interface InterestPostingDue {
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
canAct: boolean;
blockedReason: string | null;
}
export interface InterestPostingSummaryRow {
id: number;
postingDate: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
accountCount: number;
totalInterest: number;
status: InterestPostingReportStatus;
approvedBy: string | null;
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
}
export interface InterestPreviewRow {
officeId: number;
officeName: string;
productId: number;
productName: string;
savingsAccountId: number;
accountNo: string;
clientName: string;
balance: number;
rate: number;
cycleStart: string;
postingDate: string;
asOf: string;
interest: number;
}
/**
* The savings interest posting reports, one per branch and product: generated at noon on a posting
* date, approved by the branch, posted when the branch closes the day.
*/
@Injectable({ providedIn: 'root' })
export class InterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/savingsinterestpostingreports';
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: ReportScope
): Observable<InterestPostingReport[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPostingReport[]>(this.base, { params });
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
dueWithoutReport(postingDate?: string, scope?: ReportScope): Observable<InterestPostingDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<InterestPostingDue[]>(`${this.base}/due-without-report`, { params });
}
/** The signed-in user's branch, and whether it is the head office. */
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean }>(`${this.base}/context`);
}
detail(reportId: number): Observable<InterestPostingReportDetail> {
return this.http.get<InterestPostingReportDetail>(`${this.base}/${reportId}`);
}
regenerate(
productId: number,
officeId: number,
postingDate: string
): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
this.base,
{ productId, officeId, postingDate },
{ params: new HttpParams().set('command', 'regenerate') }
);
}
approve(reportId: number): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
`${this.base}/${reportId}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
/** Interest each account has earned so far this cycle, as if posted today. View only. */
preview(officeId?: number): Observable<InterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<InterestPostingSummaryRow[]> {
let params = new HttpParams();
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (productId) params = params.set('productId', String(productId));
return this.http.get<InterestPostingSummaryRow[]>(`${this.base}/summary`, { params });
}
}
@@ -0,0 +1,172 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Summary</h2>
<div class="ipr-head-s">
Savings interest across every branch: for each posting date, branch and product, how many customers were paid
and how much. Each branch approves and posts its own; this is the head office's view.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
From
<input type="date" [(ngModel)]="fromDate" (change)="load()" />
</label>
<label class="ipr-date">
To
<input type="date" [(ngModel)]="toDate" (change)="load()" />
</label>
<label class="ipr-date">
Product
<select [(ngModel)]="productId">
<option [ngValue]="null">All products</option>
@for (p of products; track p.id) {
<option [ngValue]="p.id">{{ p.name }}</option>
}
</select>
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="exportExcel()" [disabled]="loading || shown.length === 0">
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
</div>
</div>
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} branch report(s) in this period are not posted yet.</div>
</div>
}
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Branch totals</h3>
<div class="muted">{{ fromDate }} to {{ toDate }}</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ totalCustomers }} customer(s) · posted ₹{{ postedInterest | number: '1.2-2' }}</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Reports</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th class="num">Posted</th>
<th class="num">Not yet posted</th>
</tr>
</thead>
<tbody>
@for (b of byBranch; track b.officeName) {
<tr>
<td>
<strong>{{ b.officeName }}</strong>
</td>
<td class="num">{{ b.reports }}</td>
<td class="num">{{ b.customers }}</td>
<td class="num">₹{{ b.interest | number: '1.2-2' }}</td>
<td class="num">₹{{ b.posted | number: '1.2-2' }}</td>
<td class="num">₹{{ b.interest - b.posted | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report in this period.
}
</td>
</tr>
}
</tbody>
@if (byBranch.length > 0) {
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">
<strong>{{ shown.length }}</strong>
</td>
<td class="num">
<strong>{{ totalCustomers }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ postedInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest - postedInterest | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
}
</table>
</div>
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Posting date</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th>Status</th>
<th>Approved</th>
<th>Posted</th>
</tr>
</thead>
<tbody>
@for (r of shown; track r.id) {
<tr>
<td>{{ r.postingDate }}</td>
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="muted">{{ r.postedAt ? (r.postedAt | date: 'dd MMM, HH:mm') : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,244 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingReportService,
InterestPostingSummaryRow
} from '../interest-posting-report/interest-posting-report.service';
/**
* The head office's view of savings interest across branches: for each posting date, branch and
* product, how many customers were paid and how much, and whether it has been approved and posted.
* Nothing is approved here — each branch approves its own — so it is a report and an Excel export.
*/
@Component({
selector: 'mifosx-interest-posting-summary',
templateUrl: './interest-posting-summary.component.html',
styleUrls: ['../interest-posting-report/interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingSummaryComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
fromDate = '';
toDate = '';
productId: number | null = null;
loading = false;
errorMessage = '';
rows: InterestPostingSummaryRow[] = [];
ngOnInit(): void {
const today = this.businessDateIso();
this.toDate = today;
this.fromDate = `${today.slice(0, 7)}-01`;
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.summary(this.fromDate || undefined, this.toDate || undefined).subscribe({
next: (rows) => {
this.rows = rows ?? [];
this.loading = false;
},
error: (err) => {
this.loading = false;
this.rows = [];
this.errorMessage =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.message ??
'Could not load the interest summary.';
}
});
}
/** Products present in the loaded rows, for the filter. */
get products(): { id: number; name: string }[] {
const seen = new Map<number, string>();
this.rows.forEach((r) => seen.set(r.productId, r.productName));
return [...seen.entries()].map(
([
id,
name
]) => ({ id, name })
);
}
get shown(): InterestPostingSummaryRow[] {
return this.productId ? this.rows.filter((r) => r.productId === this.productId) : this.rows;
}
get totalCustomers(): number {
return this.shown.reduce((sum, r) => sum + (r.accountCount || 0), 0);
}
get totalInterest(): number {
return this.shown.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get postedInterest(): number {
return this.shown.filter((r) => r.status === 'POSTED').reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get pendingCount(): number {
return this.shown.filter((r) => r.status !== 'POSTED').length;
}
/** Branch totals, for the second table and the export. */
get byBranch(): { officeName: string; customers: number; interest: number; posted: number; reports: number }[] {
const map = new Map<
string,
{ officeName: string; customers: number; interest: number; posted: number; reports: number }
>();
this.shown.forEach((r) => {
const key = r.officeName || '—';
const row = map.get(key) ?? { officeName: key, customers: 0, interest: 0, posted: 0, reports: 0 };
row.customers += r.accountCount || 0;
row.interest += Number(r.totalInterest || 0);
row.posted += r.status === 'POSTED' ? Number(r.totalInterest || 0) : 0;
row.reports += 1;
map.set(key, row);
});
return [...map.values()].sort((a, b) => a.officeName.localeCompare(b.officeName));
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted'
}[status] ?? 'ipr-badge--muted'
);
}
exportExcel(): void {
const period = `Posting dates ${this.fromDate || '…'} to ${this.toDate || '…'}`;
const product = this.productId ? this.products.find((p) => p.id === this.productId)?.name : 'All products';
exportWorkbook(
[
{
sheetName: 'By report',
title: 'Savings Interest — Branch-wise Posting',
subtitle: `${period} · ${product}`,
headers: [
'Posting Date',
'Branch',
'Product',
'Customers',
'Interest',
'Status',
'Approved By',
'Approved At',
'Posted At'
],
widths: [
14,
22,
24,
12,
16,
12,
20,
20,
20
],
rows: [
...this.shown.map((r) => [
r.postingDate,
r.officeName,
r.productName,
r.accountCount,
Number(r.totalInterest || 0),
r.status,
(r.approvedBy || '') + (r.approvedOnBehalf ? ' (HO)' : ''),
r.approvedAt || '',
r.postedAt || ''
]),
[
'TOTAL',
'',
'',
this.totalCustomers,
this.totalInterest,
'',
'',
'',
''
]
]
},
{
sheetName: 'By branch',
title: 'Savings Interest — Branch Totals',
subtitle: `${period} · ${product}`,
headers: [
'Branch',
'Reports',
'Customers',
'Interest',
'Posted',
'Not Yet Posted'
],
widths: [
22,
10,
12,
16,
16,
16
],
rows: [
...this.byBranch.map((b) => [
b.officeName,
b.reports,
b.customers,
b.interest,
b.posted,
b.interest - b.posted
]),
[
'TOTAL',
this.shown.length,
this.totalCustomers,
this.totalInterest,
this.postedInterest,
this.totalInterest - this.postedInterest
]
]
}
],
`savings-interest-branch-summary-${this.fromDate}-to-${this.toDate}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
}
@@ -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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
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
@@ -49,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatStepperPrevious,
FaIconComponent,
MatStepperNext,
AsyncPipe
AsyncPipe,
LoanProductTemplateCardComponent
]
})
export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
@@ -59,6 +63,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
private translateService = inject(TranslateService);
private settingsService = inject(SettingsService);
private commons = inject(Commons);
private catalog = inject(LoanProductCatalogService);
//** Defining PlaceHolders for the search bar */
placeHolderLabel = '';
@@ -88,6 +93,27 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
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;
/** Currency data. */
protected productData: ReplaySubject<string[]> = new ReplaySubject<string[]>(1);
@@ -115,7 +141,8 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.maxDate = this.settingsService.maxFutureDate;
this.buildDependencies();
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) {
this.loansAccountDetailsForm.patchValue({
productId: this.loansAccountTemplate.loanProductId,
@@ -136,6 +163,44 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.searchItem();
});
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 {
@@ -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 {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
<span class="rrc-rate-now__since"
>{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span
>
}
</div>
}
@@ -62,8 +64,10 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- Both ratios are Form 1's. Part B charges section 18 on the base, Part C section
24 on the same base - one form, two parts, not two returns. -->
<mat-option value="CRR">CRR — Form 1 Part B</mat-option>
<mat-option value="SLR">SLR — Form 1 Part C</mat-option>
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
savings balances that counts as a demand liability, the rest being time. -->
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
@@ -129,11 +133,30 @@
<!-- ═══ GL accounts ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<div class="rrc-gl-head">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<!--
The two returns share a base but maintain their requirement in different assets, so an
administrator setting one up is looking at a different set of heads than one setting up
the other. Showing all of them at once buries the few that matter for the task in hand.
-->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-gl-head__filter">
<mat-label>Configuring</mat-label>
<mat-select [(ngModel)]="formFilter" (selectionChange)="onFormFilterChange()">
@for (f of formFilters; track f.value) {
<mat-option [value]="f.value">{{ f.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<p class="rrc-card__hint">
Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each
one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of
its children, so mapping both would count those children twice.
@if (formFilter !== 'ALL') {
Heads that build or net the DTL base are shown for either return, since both charge their rate on that same
base.
}
</p>
@if (unmappedCount) {
@@ -385,6 +385,20 @@ $red: #c5221f;
margin: 0 !important;
}
// Title and the return filter on one line: the filter says what the list below is showing, so it
// belongs beside the heading rather than buried among the roles.
.rrc-gl-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
&__filter {
width: 260px;
}
}
.rrc-remove {
min-width: 0 !important;
padding: 0 8px !important;
@@ -23,8 +23,11 @@ export interface ReserveMapping {
}
/**
* Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
* accounts the returns read their figures from.
* Configuration behind RBI Form 1: the percentages RBI sets, and which GL accounts the return reads
* its figures from.
*
* One form settles both ratios. Part B is section 18 (the cash reserve) and Part C is section 24
* (the liquid assets), charged on the same base, so CRR and SLR are configured together here.
*
* Both halves sit on one screen because they are one decision. A percentage applies to a base, and
* an administrator who sets the rate but never maps the accounts produces a return that looks
@@ -47,8 +50,8 @@ export class RbiReserveConfigComponent implements OnInit {
private organizationService = inject(OrganizationService);
/**
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1
* and Form 9, 'OSS1', 'OSS2'. Null shows every role.
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1,
* 'FORM9' for Form IX, 'OSS1', 'OSS2'. Null shows every role.
*
* A screen per return rather than one screen for all of them: the four roles that drive CRR and
* the thirty that do not are separate decisions, made by different people at different times,
@@ -64,6 +67,80 @@ export class RbiReserveConfigComponent implements OnInit {
/** The roles grouped by the return they feed, in backend order. */
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form IX, or both together.
*
* The two returns have nothing in common - Form 1 charges ratios on a liability base, Form IX
* reports a balance sheet - so an administrator setting one up is looking at a different set of
* heads than one setting up the other. Showing all of them at once buries the handful that matter
* for the task in hand.
*/
formFilter: 'ALL' | 'FORM_1' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
this.rebuildGroups();
}
/**
* The roles this screen shows: those in its own group, narrowed to the return selected above.
*
* The group scope is widened for Form IX. Its heads were seeded into their own group so they sit
* apart on the backend, and a screen fixed to 'RESERVE' would filter every one of them out before
* the return filter ever ran - leaving the Form IX option selectable and its list empty.
*/
private rolesForScreen(): any[] {
const wanted = this.formFilter === 'FORM_IX' ? 'FORM9' : this.group;
const scoped = wanted ? this.allRoles.filter((r: any) => r.group === wanted) : this.allRoles;
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
}
/**
* The returns this bank files, and the only ones that can be configured.
*
* There is no separate SLR return. Form 1 settles both ratios on one form - Part B is section 18,
* the cash reserve, and Part C is section 24, the liquid assets - so the heads that meet SLR are
* Form 1's heads. A "Form 9 - SLR" option used to sit here from before that was understood; it
* selected a return that has no report behind it, and filtered the SLR heads out of the one form
* that actually reports them.
*/
readonly formFilters = [
{ value: 'ALL', label: 'All returns' },
{ value: 'FORM_1', label: 'Form 1 — CRR & SLR' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* Form 1 takes every head that is not Form IX's. It reports both ratios: MAINTAINED heads are met
* in cash and balances and settle Part B, SLR_ASSET heads are met in securities and gold and
* settle Part C, and the heads that build or net the DTL base feed the IV(b) both rates are
* charged on. Splitting them across two filters only hid half of one form.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
return true;
}
// Form IX's heads are placed by the group they were seeded into, not by usage. Its usages -
// LIABILITY and ASSET - read the same as Form 1's but mean something different: on Form IX they
// say which SIDE OF THE BALANCE SHEET a head sits on, not how it enters a ratio. Sixty heads
// showing up under Form 1's filter would bury the eight that belong there.
const group = role.group || role.roleGroup;
if (group === 'FORM9') {
return this.formFilter === 'FORM_IX';
}
if (this.formFilter === 'FORM_IX') {
return false;
}
// Everything left belongs to Form 1, whichever of its two parts it settles.
return true;
}
/** Which group's roles are on screen. Unused when the screen is scoped to one group. */
activeGroup = '';
rates: any[] = [];
@@ -94,15 +171,18 @@ export class RbiReserveConfigComponent implements OnInit {
/**
* How a head an administrator adds is consumed by the return.
*
* MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
* met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
* both would let a head meant for Form 1 quietly satisfy Form 9.
* MAINTAINED and SLR_ASSET are separate because the two parts admit different assets: CRR is met
* in cash and balances with banks, SLR in cash, gold and approved securities. One option for both
* would let a head meant for Part B quietly satisfy Part C.
*/
readonly roleUsages = [
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1 Part B)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 1 Part C)' },
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
// on. LIABILITY above serves its liability side, and this its asset side.
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
{ value: 'MEMO', label: 'Memo — shown on the return but totals nothing' }
];
@@ -140,10 +220,11 @@ export class RbiReserveConfigComponent implements OnInit {
}
private applyConfig(config: any): void {
const allRoles = config.glRoles || [];
// Kept so the form filter can re-slice without a round trip.
this.allRoles = config.glRoles || [];
// Scoped to this screen's return before anything else, so every count, warning and Save on the
// page speaks about the roles the user can actually see.
this.glRoles = this.group ? allRoles.filter((r: any) => r.group === this.group) : allRoles;
this.glRoles = this.rolesForScreen();
this.rates = config.rates || [];
this.selection = {};
for (const role of this.glRoles) {
@@ -201,9 +282,9 @@ export class RbiReserveConfigComponent implements OnInit {
get screenSubtitle(): string {
return (
'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' +
'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' +
'than reporting a nil figure that looks real.'
'The CRR and SLR percentages, and the GL accounts RBI Form 1 reads its figures from — Part B settles the cash ' +
'reserve, Part C the liquid assets, both charged on the same base. A rate applies to a base — so a role left ' +
'unmapped stops the return computing rather than reporting a nil figure that looks real.'
);
}
@@ -61,12 +61,7 @@
}
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
[disabled]="savingGl || !isGlDirty(role)"
(click)="saveGl(role)"
>
<button mat-raised-button color="primary" [disabled]="savingGl || !isGlDirty(role)" (click)="saveGl(role)">
{{ savingGl ? 'Saving…' : 'Save' }}
</button>
</div>
@@ -78,8 +73,8 @@
<div class="rrc-card">
<h3 class="rrc-card__title">Financial years</h3>
<p class="rrc-card__hint">
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting
that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep
quoting that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
</p>
<div class="rrc-tabs" role="tablist">
@@ -37,7 +37,10 @@ interface FieldDef {
@Component({
selector: 'mifosx-tds-config',
templateUrl: './tds-config.component.html',
styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'],
styleUrls: [
'../rbi-reserve-config/rbi-reserve-config.component.scss',
'./tds-config.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -67,7 +70,12 @@ export class TdsConfigComponent implements OnInit {
readonly rateFields: FieldDef[] = [
{ key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' },
{ key: 'thresholdSenior', label: 'Threshold – senior citizen (₹)', kind: 'amount', hint: '60 at any time in the year' },
{
key: 'thresholdSenior',
label: 'Threshold – senior citizen (₹)',
kind: 'amount',
hint: '60 at any time in the year'
},
{ key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' },
{ key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' },
{ key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' },
@@ -96,7 +104,12 @@ export class TdsConfigComponent implements OnInit {
{ value: 'QUARTERLY', label: 'Quarterly' }
]
},
{ key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' }
{
key: 'declarationStatementDueDay',
label: 'Statement due on day',
kind: 'int',
hint: 'Of the following month / quarter'
}
];
readonly statuteFields: FieldDef[] = [
@@ -226,22 +226,18 @@ export class CurrentProductSettingsStepComponent implements OnInit {
const percent = form.get('odExcessLimitPercent');
const penal = form.get('odPenalRate');
percent.setValidators(
required
? [
required ? [
Validators.required,
Validators.min(0.01),
Validators.max(25)
]
: []
] : []
);
penal.setValidators(
required
? [
required ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: []
] : []
);
percent.updateValueAndValidity({ emitEvent: false });
penal.updateValueAndValidity({ emitEvent: false });
@@ -23,7 +23,9 @@
@if (!isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span>
<span
>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span
>
</div>
}
@@ -84,8 +84,12 @@ export class CurrentProductTermsStepComponent implements OnInit {
this.CurrentProductTermsForm.patchValue({
interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting,
interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365,
nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '',
minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '',
nominalAnnualInterestRateOverdraft: isEditing
? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft
: '',
minOverdraftForInterestCalculation: isEditing
? this.CurrentProductsTemplate.minOverdraftForInterestCalculation
: '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true
});
@@ -98,13 +102,11 @@ export class CurrentProductTermsStepComponent implements OnInit {
private applyOverdraftValidators(allowed: boolean) {
const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft');
rate.setValidators(
allowed
? [
allowed ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: [Validators.min(0)]
] : [Validators.min(0)]
);
rate.updateValueAndValidity({ emitEvent: false });
}
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Fixed Deposit Interest Paid';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -128,6 +132,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -158,6 +163,20 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
return '';
}
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.fixedDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private patchAccountingValues(mappings: any) {
this.fixedDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: this.extractAccountId(
@@ -200,7 +219,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -266,10 +296,26 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.fixedDepositProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -416,6 +462,13 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Fixed Deposit Interest Paid',
inputLabel: 'Fixed deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -456,6 +509,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
savingsInterestPaidAccountId,
...accounting
} = this.fixedDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -0,0 +1,96 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ca" *ngIf="register">
<div class="ca__readonly-banner" *ngIf="!canEdit">
<i class="fas fa-lock"></i>
<div>
<div class="ca__readonly-title">Read-only — {{ register.status }}</div>
<div class="ca__readonly-sub">{{ readOnlyReason() }}</div>
</div>
</div>
<div class="ca__active-strip" *ngIf="active">
<span class="ca__active-dot">●</span>
<span class="ca__active-label">CURRENT ACTIVE</span>
<span class="ca__active-name">{{ active.name }}</span>
<span class="ca__active-sub">· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}</span>
<span class="ca__active-flag" *ngIf="isActiveRegister()">You are viewing the Active register</span>
<a class="ca__active-link" *ngIf="!isActiveRegister()" (click)="viewActive()">View active →</a>
</div>
<div class="ca__header">
<div class="ca__header-icon">CS</div>
<div class="ca__header-text">
<h2>CIBIL / credit score adjustment</h2>
<p>Applied on top of the chart rate at loan sanction · reused across all loan products linked to this register</p>
</div>
<button mat-stroked-button (click)="backToRegister()">← Back to register</button>
</div>
<div class="ca__card">
<div class="ca__card-head">
<div>
<div class="ca__card-title">Credit score slabs</div>
<div class="ca__card-sub">
Only the Adjustment % is editable · changes apply to new sanctions after the register is approved
</div>
</div>
<button *ngIf="canEdit" mat-stroked-button class="ca__dash-btn" (click)="addSlab()">
<i class="fas fa-plus"></i> Add slab
</button>
</div>
<table class="ca__editgrid">
<thead>
<tr>
<th class="ca__col-ix">#</th>
<th class="ca__num">Score from</th>
<th class="ca__num">Score to</th>
<th>Risk tier</th>
<th>Type</th>
<th class="ca__num">Adjustment</th>
<th class="ca__col-del"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of register.cibilSlabs; let i = index">
<td class="ca__col-ix">{{ i + 1 }}</td>
<td class="ca__num ca__cell-readonly">{{ slab.from }}</td>
<td class="ca__num ca__cell-readonly">{{ slab.to }}</td>
<td>
<span [class]="tierClass(slab.tier)">{{ slab.tier }}</span>
</td>
<td class="ca__cell-muted">{{ slab.type }}</td>
<td class="ca__num">
<div class="ca__adj-wrap">
<input
[class]="adjustInputClass(slab.adjustmentPct)"
type="number"
step="0.05"
[(ngModel)]="slab.adjustmentPct"
[readonly]="!canEdit"
/>
<span class="ca__adj-suffix">%</span>
</div>
</td>
<td class="ca__col-del">
<button *ngIf="canEdit" type="button" class="ca__delete-btn" title="Delete slab" (click)="deleteSlab(i)">
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="ca__footer">
<button mat-stroked-button (click)="backToRegister()">{{ canEdit ? 'Cancel' : 'Back to register' }}</button>
<button *ngIf="canEdit" mat-flat-button color="primary" (click)="save()">Save adjustments</button>
</div>
</div>
@@ -0,0 +1,414 @@
/**
* 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/.
*/
.ca {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
&__active-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 10px;
font-size: 12px;
color: #065f46;
}
&__active-dot {
color: #059669;
font-size: 14px;
line-height: 1;
}
&__active-label {
font-size: 10px;
letter-spacing: 0.5px;
font-weight: 700;
color: #065f46;
}
&__active-name {
font-weight: 600;
color: #064e3b;
}
&__active-sub {
color: #047857;
}
&__active-flag {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: #047857;
background: #d1fae5;
padding: 3px 10px;
border-radius: 999px;
}
&__active-link {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #047857;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__readonly-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-left: 4px solid #d97706;
border-radius: 10px;
color: #78350f;
i {
font-size: 16px;
color: #d97706;
}
}
&__readonly-title {
font-size: 13px;
font-weight: 700;
color: #78350f;
}
&__readonly-sub {
margin-top: 2px;
font-size: 12px;
color: #92400e;
}
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__header-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: #dbeafe;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.3px;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #0f172a;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__card-head {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
&__card-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__card-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__dash-btn {
height: 32px;
font-size: 12px;
font-weight: 600;
color: #1d4ed8;
border: 1px dashed #1d4ed8;
padding: 0 14px;
i {
margin-right: 6px;
}
}
&__num {
text-align: right;
}
&__col-ix {
width: 44px;
text-align: center;
color: #94a3b8;
font-weight: 600;
}
&__col-del {
width: 48px;
text-align: center;
}
&__cell-muted {
color: #64748b;
}
&__cell-readonly {
color: #0f172a;
font-variant-numeric: tabular-nums;
padding: 0 22px;
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 4px 2px;
}
}
/* ---- Editable grid ---------------------------------------------------- */
.ca__editgrid {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead tr {
background: #f8fafc;
th {
text-align: left;
padding: 10px 16px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
font-weight: 600;
border-bottom: 1px solid #e2e8f0;
&.ca__num {
text-align: right;
}
}
}
tbody tr {
border-bottom: 1px solid #f1f5f9;
transition: background 0.12s ease;
&:hover {
background: #f8fafc;
.ca__delete-btn {
opacity: 1;
}
}
&:last-child {
border-bottom: 0;
}
td {
padding: 8px 12px;
vertical-align: middle;
}
}
}
/* Borderless cell input — gets a visible border on hover/focus. */
.ca__ghost-input {
width: 100%;
height: 32px;
border: 1px solid transparent;
background: transparent;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
color: #0f172a;
font-family: inherit;
box-sizing: border-box;
&:hover {
border-color: #e2e8f0;
background: white;
}
&:focus {
outline: none;
border-color: #1d4ed8;
background: white;
box-shadow: 0 0 0 3px rgb(29 78 216 / 10%);
}
&--num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Hide number input spinners for a cleaner look. */
&[type='number']::-webkit-inner-spin-button,
&[type='number']::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
&[type='number'] {
appearance: textfield;
}
}
/* Adjustment input — coloured by sign, with trailing % suffix. */
.ca__adj-wrap {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
width: 100%;
}
.ca__adj-input {
width: 72px;
font-weight: 700;
&--negative {
color: #059669;
}
&--positive {
color: #d97706;
}
&--high {
color: #dc2626;
}
}
.ca__adj-suffix {
color: #94a3b8;
font-size: 12px;
font-weight: 600;
}
/* Chip-shaped tier select — background matches the current tier. */
.ca__tier-select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%2364748b' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
padding: 4px 26px 4px 12px;
height: 28px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgb(29 78 216 / 15%);
}
&--vpoor {
background-color: #fecaca;
color: #7f1d1d;
}
&--poor {
background-color: #fee2e2;
color: #991b1b;
}
&--fair {
background-color: #fef3c7;
color: #92400e;
}
&--good {
background-color: #dbeafe;
color: #1e40af;
}
&--excellent {
background-color: #d1fae5;
color: #065f46;
}
}
/* Trash button — hidden until row hover. */
.ca__delete-btn {
width: 28px;
height: 28px;
border: 1px solid transparent;
background: transparent;
color: #94a3b8;
border-radius: 6px;
font-size: 12px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition:
opacity 0.12s ease,
color 0.12s ease,
border-color 0.12s ease,
background 0.12s ease;
&:hover {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
&:focus-visible {
opacity: 1;
}
}
@@ -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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateRegister } from '../models/rate-register.model';
/**
* CIBIL / credit-score slab editor.
* Score range, tier and type are fixed per slab; only the Adjustment % is editable.
* New slabs can be appended (and removed) via Add slab / row trash.
*/
@Component({
selector: 'mifosx-cibil-adjustment',
templateUrl: './cibil-adjustment.component.html',
styleUrls: ['./cibil-adjustment.component.scss'],
imports: [
CommonModule,
FormsModule,
MatButtonModule
]
})
export class CibilAdjustmentComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
register!: RateRegister;
active?: RateRegister;
ngOnInit(): void {
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
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();
});
}
isActiveRegister(): boolean {
return !!this.active && this.active.id === this.register?.id;
}
viewActive(): void {
if (this.active) {
this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.active.id } });
}
}
/** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */
get canEdit(): boolean {
return this.register?.status === 'Draft';
}
readOnlyReason(): string {
switch (this.register?.status) {
case 'Pending Approval':
return 'This version has been submitted and is awaiting checker approval. No further edits allowed.';
case 'Active':
return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes.';
case 'Expired':
return 'This is an inactive historical version. Read-only.';
default:
return 'Read-only.';
}
}
tierClass(tier: CibilSlab['tier']): string {
const map: Record<string, string> = {
'V. Poor': 'tier tier--vpoor',
Poor: 'tier tier--poor',
Fair: 'tier tier--fair',
Good: 'tier tier--good',
Excellent: 'tier tier--excellent'
};
return map[tier] ?? 'tier';
}
/** Classes to colour the adjustment number by sign/magnitude. */
adjustInputClass(pct: number): string {
const base = 'ca__ghost-input ca__ghost-input--num ca__adj-input';
if (pct < 0) {
return `${base} ca__adj-input--negative`;
}
if (pct >= 1) {
return `${base} ca__adj-input--high`;
}
if (pct > 0) {
return `${base} ca__adj-input--positive`;
}
return base;
}
/** Append a new slab continuing from the current highest "score to". */
addSlab(): void {
if (!this.canEdit) {
return;
}
const lastTo = this.register.cibilSlabs.length ? Math.max(...this.register.cibilSlabs.map((s) => s.to)) : 0;
const from = Math.min(lastTo + 1, 900);
this.register.cibilSlabs.push({
from,
to: Math.min(from + 50, 900),
tier: 'Fair',
type: 'Flat',
adjustmentPct: 0
});
}
deleteSlab(index: number): void {
if (!this.canEdit) {
return;
}
this.register.cibilSlabs.splice(index, 1);
}
/** Edits are kept in the in-memory service (the register reference is shared). */
save(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } });
}
backToRegister(): void {
this.save();
}
}
@@ -0,0 +1,199 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="er" *ngIf="register">
<div class="er__active-strip" *ngIf="active">
<span class="er__active-dot">●</span>
<span class="er__active-label">CURRENT ACTIVE</span>
<span class="er__active-name">{{ active.name }}</span>
<span class="er__active-sub">· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}</span>
<span class="er__active-flag" *ngIf="isActiveRegister()">You are viewing the Active register</span>
<a class="er__active-link" *ngIf="!isActiveRegister()" (click)="viewActive()">View active →</a>
</div>
<div class="er__readonly-banner" *ngIf="!canEdit">
<i class="fas fa-lock"></i>
<div>
<div class="er__readonly-title">Read-only — {{ register.status }}</div>
<div class="er__readonly-sub">{{ readOnlyReason() }}</div>
</div>
</div>
<div class="er__meta-bar">
<div class="er__field">
<label>REGISTER NAME</label>
<input type="text" [(ngModel)]="register.name" [readonly]="!canEdit" placeholder="Entered when you submit" />
</div>
<div class="er__field er__field--date">
<label>EFFECTIVE FROM</label>
<input type="date" [(ngModel)]="register.effectiveFrom" [readonly]="!canEdit" />
</div>
<div class="er__field er__field--date">
<label>EFFECTIVE TO</label>
<input type="date" [(ngModel)]="register.effectiveTo" [readonly]="!canEdit" />
</div>
<div class="er__field er__field--curr">
<label>CURRENCY</label>
<select [(ngModel)]="register.currency" [disabled]="!canEdit">
<option value="INR">INR</option>
<option value="USD">USD</option>
</select>
</div>
<button mat-stroked-button class="er__meta-btn" *ngIf="canEdit"><i class="fas fa-arrow-down"></i> Import</button>
<button mat-stroked-button class="er__meta-btn"><i class="fas fa-arrow-up"></i> Export</button>
</div>
<div class="er__grid-card">
<div class="er__grid-head">
<div class="er__grid-title">Rate chart grid</div>
<div class="er__grid-sub">
{{ rows.length }} products × {{ slabs.length }} amount slabs
<span *ngIf="canEdit">· click any cell to edit</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 class="er__grid-actions" *ngIf="canEdit">
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
<i class="fas fa-plus"></i> Add product
</button>
<button mat-stroked-button class="er__dash-btn" (click)="addSlab()">
<i class="fas fa-plus"></i> Add slab
</button>
</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">
<table class="er__grid">
<thead>
<tr>
<th class="er__col-particulars">Particulars</th>
<th *ngFor="let slab of slabs" class="er__col-rate">{{ slab.label }}</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of rows; let i = index"
[class.er__row-alt]="i % 2 === 1"
[class.er__row--formula]="isFormulaRow(row)"
>
<td class="er__col-particulars">
<span class="er__srno">{{ row.srNo }}</span
>{{ 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
*ngFor="let cell of row.rates; let j = index"
class="er__col-rate er__cell"
[class.er__cell--na]="isNa(cell)"
[class.er__cell--formula]="isFormulaRow(row)"
[class.er__cell--editing]="isEditing(i, j)"
[class.er__cell--readonly]="!canEdit"
(click)="canEdit && !isEditing(i, j) && startEdit(i, j, cell)"
>
<input
*ngIf="isEditing(i, j); else cellView"
#cellInput
type="text"
class="er__cell-input"
[(ngModel)]="editValue"
(blur)="saveEdit(i, j)"
(keydown.enter)="saveEdit(i, j)"
(keydown.escape)="cancelEdit()"
/>
<ng-template #cellView>{{ display(cell) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="er__cibil-strip" (click)="editCibil()">
<div class="er__cibil-badge">CS</div>
<div class="er__cibil-text">
<div class="er__cibil-title">CIBIL / credit score adjustment</div>
<div class="er__cibil-sub">
{{ register.cibilSlabs.length }} slabs · +2.00% (very poor) to base (above 700) · applied on top of chart rate
</div>
</div>
<a class="er__cibil-edit">Edit →</a>
</div>
<div class="er__footer">
<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-actions" *ngIf="canEdit">
<button mat-stroked-button (click)="saveDraft()">Save draft</button>
<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
</button>
</div>
</div>
</div>
@@ -0,0 +1,496 @@
/**
* 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/.
*/
.er {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
&__active-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 9px;
font-size: 12px;
color: #065f46;
}
&__active-dot {
color: #059669;
font-size: 14px;
line-height: 1;
}
&__active-label {
font-size: 10px;
letter-spacing: 0.5px;
font-weight: 700;
color: #065f46;
}
&__active-name {
font-weight: 600;
color: #064e3b;
}
&__active-sub {
color: #047857;
}
&__active-flag {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: #047857;
background: #d1fae5;
padding: 3px 10px;
border-radius: 999px;
}
&__active-link {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #047857;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__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 {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-left: 4px solid #d97706;
border-radius: 9px;
color: #78350f;
i {
font-size: 16px;
color: #d97706;
}
}
&__readonly-title {
font-size: 13px;
font-weight: 700;
color: #78350f;
}
&__readonly-sub {
margin-top: 2px;
font-size: 12px;
color: #92400e;
}
&__meta-bar {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 12px 14px;
display: flex;
gap: 10px;
align-items: flex-end;
}
&__field {
display: flex;
flex-direction: column;
flex: 1.5;
label {
font-size: 10px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 3px;
}
input,
select {
height: 32px;
border: 1px solid #cbd5e1;
background: white;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
font-family: inherit;
color: #0f172a;
&:focus {
outline: 2px solid #1d4ed8;
outline-offset: -1px;
}
}
&--date {
flex: 0.9;
}
&--curr {
flex: 0.5;
}
}
&__meta-btn {
height: 32px;
font-size: 11px;
i {
margin-right: 4px;
}
}
&__grid-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 500px;
}
&__grid-head {
padding: 8px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 10px;
}
&__grid-title {
font-size: 12px;
font-weight: 600;
}
&__grid-sub {
font-size: 10px;
color: #64748b;
}
&__grid-actions {
margin-left: auto;
display: flex;
gap: 6px;
}
&__dash-btn {
height: 26px;
font-size: 10px;
font-weight: 600;
color: #1d4ed8;
border: 1px dashed #1d4ed8;
i {
margin-right: 3px;
}
}
&__grid-scroll {
overflow: auto;
flex: 1;
}
&__grid {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 11px;
thead tr {
background: #f8fafc;
color: #475569;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
padding: 8px 7px;
font-weight: 600;
position: sticky;
top: 0;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
z-index: 2;
white-space: nowrap;
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
}
td {
padding: 7px;
text-align: right;
cursor: text;
&:first-child {
text-align: left;
}
}
}
&__col-particulars {
position: sticky;
left: 0;
background: white;
border-right: 1px solid #e2e8f0;
font-weight: 500;
min-width: 240px;
text-align: left !important;
padding-left: 12px !important;
}
&__row-alt &__col-particulars {
background: #fafbfc;
}
&__row-alt {
background: #fafbfc;
}
&__row--formula {
.er__col-particulars {
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
}
&__srno {
color: #94a3b8;
margin-right: 6px;
}
&__cell {
cursor: cell;
transition: background-color 0.1s;
&:hover {
background: #f1f5f9;
outline: 1px solid #cbd5e1;
outline-offset: -1px;
}
&--readonly {
cursor: default;
&:hover {
background: transparent;
outline: none;
}
}
&--na {
color: #cbd5e1;
}
&--formula {
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
&--editing {
padding: 0 !important;
background: #fef9c3 !important;
outline: 2px solid #1d4ed8 !important;
outline-offset: -2px;
}
}
&__cell-input {
width: 100%;
height: 100%;
min-height: 28px;
border: none;
outline: none;
background: transparent;
padding: 7px;
font-size: 11px;
text-align: right;
font-family: inherit;
color: #0f172a;
box-sizing: border-box;
}
&__cibil-strip {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 9px 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background-color 0.1s;
&:hover {
background: #f8fafc;
}
}
&__cibil-badge {
width: 24px;
height: 24px;
border-radius: 12px;
background: #dbeafe;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
&__cibil-text {
flex: 1;
}
&__cibil-title {
font-size: 12px;
font-weight: 600;
}
&__cibil-sub {
font-size: 10px;
color: #64748b;
margin-top: 1px;
}
&__cibil-edit {
font-size: 11px;
font-weight: 600;
color: #1d4ed8;
cursor: pointer;
}
&__footer {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__footer-note {
font-size: 11px;
color: #64748b;
}
&__footer-actions {
display: flex;
gap: 6px;
}
&__submit {
background: #059669 !important;
color: white !important;
font-weight: 600;
i {
margin-right: 4px;
}
}
}
@@ -0,0 +1,363 @@
/**
* 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, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatIconModule } from '@angular/material/icon';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { RateRegisterService } from '../rate-register.service';
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
* amount slabs as columns. Cells can hold a plain rate, N.A., or a formula ("1% + FD"). All
* data today comes from {@link RateRegisterService}; wire-up to the real API comes later.
*/
@Component({
selector: 'mifosx-edit-register',
templateUrl: './edit-register.component.html',
styleUrls: ['./edit-register.component.scss'],
imports: [
CommonModule,
FormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatIconModule,
MatDatepickerModule,
MatNativeDateModule,
GlPickerComponent
]
})
export class EditRegisterComponent implements OnInit, OnDestroy {
private readonly snack = inject(MatSnackBar);
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
register!: RateRegister;
active?: RateRegister;
slabs: SlabColumn[] = [];
rows: RateChartRow[] = [];
/** The cell currently being edited (row/column indices into the grid), or null when nothing is open. */
editing: { row: number; col: number } | null = null;
editValue = '';
@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 {
this.service.getGlOptions().subscribe((options) => (this.glOptions = options));
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
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.slabs = this.register.slabs;
this.rows = this.register.rows;
});
}
isActiveRegister(): boolean {
return !!this.active && this.active.id === this.register?.id;
}
/** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */
get canEdit(): boolean {
return this.register?.status === 'Draft';
}
/** Status label shown on the read-only banner. */
readOnlyReason(): string {
switch (this.register?.status) {
case 'Pending Approval':
return 'This version has been submitted and is awaiting checker approval. No further edits allowed.';
case 'Active':
return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes as a new Draft.';
case 'Expired':
return 'This is an inactive historical version. Read-only.';
default:
return 'Read-only.';
}
}
viewActive(): void {
if (this.active) {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.active.id } });
}
}
/** Formatted display for a cell. */
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
isFormulaRow(row: RateChartRow): boolean {
return !!row.isFormula;
}
isNa(cell: RateCell): boolean {
return cell === null;
}
/** Click on a cell → open it as an editable input, pre-filled with the current value. */
startEdit(rowIdx: number, colIdx: number, current: RateCell): void {
if (!this.canEdit) {
return;
}
this.editing = { row: rowIdx, col: colIdx };
if (current === null) {
this.editValue = '';
} else if (typeof current === 'string') {
this.editValue = current;
} else {
this.editValue = current.toString();
}
// Let the view render the input, then focus and select its contents.
setTimeout(() => {
const el = this.cellInput?.nativeElement;
if (el) {
el.focus();
el.select();
}
}, 0);
}
isEditing(rowIdx: number, colIdx: number): boolean {
return !!this.editing && this.editing.row === rowIdx && this.editing.col === colIdx;
}
/**
* Save the cell:
* - empty / "N.A." / "NA" → the slab is marked unavailable
* - value with letters, "%" or "+" → persisted as a formula string ("1% + FD")
* - plain number → stored as a number so it formats as a %
*/
saveEdit(rowIdx: number, colIdx: number): void {
if (!this.editing) {
return;
}
const raw = this.editValue.trim();
let next: RateCell;
if (!raw || raw.toLowerCase() === 'n.a.' || raw.toLowerCase() === 'na') {
next = null;
} else if (/[a-zA-Z+%]/.test(raw)) {
next = raw;
} else {
const num = Number(raw);
next = Number.isFinite(num) ? num : this.rows[rowIdx].rates[colIdx];
}
this.rows[rowIdx].rates[colIdx] = next;
this.rows[rowIdx].isFormula = this.rows[rowIdx].rates.some((c) => typeof c === 'string');
this.editing = null;
}
cancelEdit(): void {
this.editing = null;
}
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 {
if (!this.canEdit) {
return;
}
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) {
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;
}
const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1;
this.rows.push({
srNo: next,
particulars: name,
glAccountId: this.newProductGl,
rates: Array(this.slabs.length).fill(null),
isFormula: false
});
this.cancelAddProduct();
}
/** 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 {
if (!this.canEdit) {
return;
}
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) {
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;
}
this.slabs.push({
key: `s_${Date.now()}`,
label,
amountUpto: hasAmount ? (amount as number) : null
});
this.rows.forEach((r) => r.rates.push(null));
this.cancelAddSlab();
}
editCibil(): void {
this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.register.id } });
}
backToList(): void {
this.router.navigate(['/products/loan-interest-rate-register']);
}
/** Opens the Compare screen; the actual submit happens from there once the maker has reviewed the diff. */
submitForApproval(): void {
this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: this.register.id } });
}
statusChipClass(): string {
switch (this.register?.status) {
case 'Active':
return 'chip chip--active';
case 'Draft':
return 'chip chip--draft';
case 'Pending Approval':
return 'chip chip--pending';
default:
return 'chip chip--expired';
}
}
}
@@ -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;
}
}
@@ -0,0 +1,183 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="lirr">
<div class="lirr__header">
<div class="lirr__header-icon"><i class="fas fa-percentage"></i></div>
<div class="lirr__header-text">
<h2>Loan Interest Rate Register</h2>
<p>
Manage the central CIBIL-based rate chart used across all loan products. Submitting a new version routes through
maker-checker — once approved, the current Active version is <strong>deactivated</strong> and the new one
<strong>activated</strong>.
</p>
</div>
<button mat-stroked-button class="lirr__excel-btn">
<i class="fas fa-exchange-alt"></i>
Excel
</button>
</div>
<div class="lirr__body">
<div class="lirr__kpi-grid">
<div class="lirr__kpi">
<div class="lirr__kpi-label">ACTIVE REGISTER</div>
<div class="lirr__kpi-value">{{ active?.name ?? 'None' }}</div>
<div class="lirr__kpi-sub lirr__kpi-sub--ok">Effective {{ activeWindow() }}</div>
</div>
<div class="lirr__kpi">
<div class="lirr__kpi-label">SLABS</div>
<div class="lirr__kpi-value">10 amount slabs</div>
<div class="lirr__kpi-sub">₹10k → &gt; ₹25L</div>
</div>
<div class="lirr__kpi">
<div class="lirr__kpi-label">CIBIL ADJUSTMENTS</div>
<div class="lirr__kpi-value">{{ active?.cibilSlabs?.length ?? 0 }} slabs</div>
<div class="lirr__kpi-sub">+2.00% (poor) → base (above 700)</div>
</div>
</div>
<div class="lirr__history">
<div class="lirr__history-head">
<div>
<div class="lirr__history-title">Loan Interest Rate Register — Version History</div>
<div class="lirr__history-sub">All versions, newest first · click a version to expand its rate chart</div>
</div>
</div>
<div class="lirr__empty" *ngIf="registers.length === 0">
<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-sub">
Create the first version to publish rates for all loan products. It starts with the standard loan types (rates
blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval.
</div>
<button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
<i class="fas fa-plus"></i> Create first version
</button>
</div>
<mat-accordion class="lirr__accordion" multi *ngIf="registers.length > 0">
<mat-expansion-panel
*ngFor="let r of registers"
class="lirr__panel"
[class.lirr__panel--active]="r.status === 'Active'"
[expanded]="r.status === 'Active'"
>
<mat-expansion-panel-header class="lirr__panel-header">
<mat-panel-title class="lirr__panel-title">
<span class="lirr__panel-name">{{ r.name || '(Untitled draft)' }}</span>
<span [class]="statusChipClass(r.status)">{{ statusLabel(r.status) }}</span>
</mat-panel-title>
<mat-panel-description class="lirr__panel-desc">
<span class="lirr__panel-range">{{ effectiveRange(r) }}</span>
<span class="lirr__panel-actions">
<ng-container [ngSwitch]="r.status">
<ng-container *ngSwitchCase="'Pending Approval'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openCompare(r, $event)"
>
Compare
</button>
<span class="lirr__row-note">Awaiting checker · action from Approvals tab</span>
</ng-container>
<ng-container *ngSwitchCase="'Draft'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openEditor(r, $event)"
>
Edit · Submit
</button>
</ng-container>
<ng-container *ngSwitchCase="'Active'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="editAsNewVersion(r, $event)"
>
Edit (new version)
</button>
</ng-container>
<ng-container *ngSwitchDefault>
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openEditor(r, $event)"
>
View
</button>
</ng-container>
</ng-container>
</span>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="lirr__panel-body">
<div class="lirr__panel-meta" *ngIf="r.note">{{ r.note }}</div>
<div class="lirr__panel-meta">
Created by {{ r.createdBy }} · Last modified {{ r.lastModified | date: 'dd MMM yyyy' }}
</div>
<h4 class="lirr__panel-section">Rate chart ({{ r.rows.length }} products × {{ r.slabs.length }} slabs)</h4>
<div class="lirr__panel-scroll">
<table class="lirr__panel-grid">
<thead>
<tr>
<th class="lirr__panel-grid-first">Particulars</th>
<th *ngFor="let s of r.slabs" class="lirr__panel-grid-num">{{ s.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of r.rows">
<td class="lirr__panel-grid-first">
<span class="lirr__panel-grid-srno">{{ row.srNo }}</span
>{{ 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 *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td>
</tr>
</tbody>
</table>
</div>
<h4 class="lirr__panel-section">CIBIL / credit-score slabs</h4>
<table class="lirr__panel-cibil">
<thead>
<tr>
<th>#</th>
<th>Score from</th>
<th>Score to</th>
<th>Risk tier</th>
<th>Type</th>
<th>Adjustment</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of r.cibilSlabs; let i = index">
<td>{{ i + 1 }}</td>
<td>{{ slab.from }}</td>
<td>{{ slab.to }}</td>
<td>{{ slab.tier }}</td>
<td>{{ slab.type }}</td>
<td>{{ formatPct(slab.adjustmentPct) }}</td>
</tr>
</tbody>
</table>
</div>
</mat-expansion-panel>
</mat-accordion>
<div class="lirr__history-foot">Showing {{ registers.length }} versions</div>
</div>
</div>
</div>
@@ -0,0 +1,524 @@
/**
* 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/.
*/
.lirr {
background: #f8fafc;
min-height: calc(100vh - 160px);
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 22px 32px 14px;
background: white;
border-bottom: 1px solid #e2e8f0;
}
&__header-icon {
width: 40px;
height: 40px;
border-radius: 9px;
background: #eff6ff;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__excel-btn,
&__create-btn {
i {
margin-right: 6px;
}
}
&__body {
padding: 20px 32px;
}
&__kpi-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 18px;
}
&__kpi {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
}
&__kpi-label {
font-size: 11px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
}
&__kpi-value {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
&__kpi-sub {
font-size: 11px;
color: #64748b;
margin-top: 4px;
&--ok {
color: #059669;
}
}
&__table-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
&__table-head {
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
}
&__table-title {
font-size: 14px;
font-weight: 600;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead {
background: #f8fafc;
color: #64748b;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
text-align: left;
padding: 10px 12px;
font-weight: 600;
&:first-child {
padding-left: 20px;
}
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
cursor: pointer;
transition: background-color 0.1s;
&:hover {
background: #f8fafc;
}
td {
padding: 12px;
&:first-child {
padding-left: 20px;
}
}
}
}
&__row--active {
background: #f0fdf4;
}
&__name {
font-weight: 600;
}
&__name-sub {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
&__row-actions {
text-align: right;
color: #64748b;
padding-right: 20px !important;
white-space: nowrap;
}
&__row-btn {
height: 28px;
font-size: 11px;
font-weight: 600;
padding: 0 10px;
margin-left: 6px;
border-radius: 6px;
&--neutral {
color: #334155;
border-color: #cbd5e1;
background: white;
&:hover {
background: #f8fafc;
}
}
&--approve {
color: #065f46;
border-color: #a7f3d0;
background: #ecfdf5;
&:hover {
background: #d1fae5;
}
}
&--reject {
color: #991b1b;
border-color: #fecaca;
background: #fef2f2;
&:hover {
background: #fee2e2;
}
}
}
&__row-note {
font-size: 11px;
color: #64748b;
font-style: italic;
margin-left: 10px;
}
&__table-foot {
padding: 10px 20px;
border-top: 1px solid #f1f5f9;
font-size: 12px;
color: #64748b;
}
/* ---- Version History accordion (CBS BIR style) ------------------------ */
&__history {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__history-head {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__history-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__history-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__accordion {
display: block;
padding: 8px 12px 16px;
}
&__panel {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
margin-bottom: 10px;
box-shadow: none !important;
overflow: hidden;
&.mat-expanded {
border-color: #cbd5e1 !important;
}
&--active {
border-left: 3px solid #10b981 !important;
}
}
&__panel-header {
padding: 0 20px !important;
height: 56px;
}
&__panel-title {
display: flex !important;
align-items: center !important;
gap: 12px !important;
flex: 0 0 auto !important;
margin-right: 24px !important;
}
&__panel-name {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
&__panel-desc {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 12px;
flex: 1 1 auto !important;
color: #475569 !important;
}
&__panel-range {
font-size: 12px;
color: #475569;
font-variant-numeric: tabular-nums;
}
&__panel-actions {
display: inline-flex;
gap: 6px;
align-items: center;
}
&__panel-body {
padding: 8px 20px 20px;
font-size: 12px;
color: #334155;
display: flex;
flex-direction: column;
gap: 10px;
}
&__panel-meta {
font-size: 12px;
color: #64748b;
}
&__panel-section {
margin: 10px 0 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
font-weight: 700;
}
&__panel-scroll {
overflow: auto;
max-height: 360px;
border: 1px solid #f1f5f9;
border-radius: 8px;
}
&__panel-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;
letter-spacing: 0.3px;
font-weight: 600;
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
}
}
&__panel-grid-first {
text-align: left;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
&__panel-grid-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
&__panel-grid-srno {
display: inline-block;
min-width: 22px;
color: #94a3b8;
font-weight: 600;
margin-right: 6px;
}
&__panel-cibil {
width: 100%;
border-collapse: collapse;
font-size: 12px;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
thead th {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
tbody td {
padding: 8px 12px;
border-bottom: 1px solid #f1f5f9;
}
}
&__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 {
padding: 10px 20px;
border-top: 1px solid #f1f5f9;
font-size: 12px;
color: #64748b;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 56px 24px 48px;
text-align: center;
}
&__empty-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: #ede9fe;
color: #6d28d9;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 4px;
}
&__empty-title {
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
&__empty-sub {
font-size: 13px;
color: #64748b;
max-width: 560px;
line-height: 1.5;
}
&__empty-btn {
margin-top: 8px;
i {
margin-right: 6px;
}
}
}
.chip {
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
&--active {
background: #dcfce7;
color: #166534;
}
&--draft {
background: #fef3c7;
color: #92400e;
}
&--pending {
background: #ede9fe;
color: #5b21b6;
}
&--expired {
background: #f1f5f9;
color: #475569;
}
&--scheduled {
background: #e0e7ff;
color: #3730a3;
}
}
@@ -0,0 +1,197 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service';
import { CibilSlab, RateCell, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model';
/**
* List screen for the central Loan Interest Rate Register — three KPI cards followed by the
* registers table (name, effective window, status, author, last modified).
*/
@Component({
selector: 'mifosx-loan-interest-rate-register',
templateUrl: './loan-interest-rate-register.component.html',
styleUrls: ['./loan-interest-rate-register.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
DatePipe,
MatExpansionModule
]
})
export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
registers: RateRegister[] = [];
active?: RateRegister;
ngOnInit(): void {
// Load the GL list once so mapped particulars show a readable label instead of "GL #id".
this.service.getGlOptions().subscribe();
this.reload();
}
glLabel(id: number | null | undefined): string | null {
return this.service.glLabel(id);
}
private reload(): void {
this.service.list().subscribe((rows) => {
this.registers = rows;
this.active = rows.find((r) => r.status === 'Active');
});
}
edit(register: RateRegister): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: register.id } });
}
preview(register: RateRegister): void {
this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: register.id } });
}
/** Route by status: Pending → Compare screen (checker view); otherwise the editor. */
openRow(register: RateRegister): void {
if (register.status === 'Pending Approval') {
this.preview(register);
} else {
this.edit(register);
}
}
/** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void {
event.stopPropagation();
this.service.approve(register.id).subscribe((updated) => {
if (updated) {
this.snack.open(`${updated.name} activated`, 'OK', { duration: 3000 });
this.reload();
}
});
}
/** Checker rejects the pending register — it goes back to Draft. */
reject(register: RateRegister, event: Event): void {
event.stopPropagation();
this.service.reject(register.id).subscribe((updated) => {
if (updated) {
this.snack.open(`${updated.name} sent back to Draft`, 'OK', { duration: 3000 });
this.reload();
}
});
}
statusChipClass(status: RateRegister['status']): string {
switch (status) {
case 'Active':
return 'chip chip--active';
case 'Draft':
return 'chip chip--draft';
case 'Pending Approval':
return 'chip chip--pending';
case 'Expired':
return 'chip chip--expired';
default:
return 'chip chip--scheduled';
}
}
statusLabel(status: RateRegister['status']): string {
switch (status) {
case 'Active':
return '● Active';
case 'Pending Approval':
return '⏳ Pending Approval';
default:
return status;
}
}
activeWindow(): string {
if (!this.active) {
return '—';
}
return `${this.formatShort(this.active.effectiveFrom)} → ${this.formatShort(this.active.effectiveTo)}`;
}
/** Panel header subtitle — effective date range, with "Current" for the active one. */
effectiveRange(r: RateRegister): string {
const from = this.formatShort(r.effectiveFrom);
const to = r.status === 'Active' ? 'Current' : this.formatShort(r.effectiveTo);
return `From ${from} To ${to}`;
}
/** Compact display of a rate cell (for the read-only expanded view). */
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
}
/** Open this register's full editor (grid) — stops the row click from toggling the panel. */
openEditor(r: RateRegister, event: Event): void {
event.stopPropagation();
this.edit(r);
}
/**
* "Edit (new version)" on Active: duplicate the Active register as a new Draft first, then
* open the Draft in the editor. The live Active is never mutated this way.
*/
editAsNewVersion(r: RateRegister, event: Event): void {
event.stopPropagation();
const draft = this.service.duplicateAsDraft(r.id);
if (draft) {
this.snack.open('New draft created from Active — editing the copy', 'OK', { duration: 2500 });
this.reload();
this.edit(draft);
}
}
/** Scaffold the very first version — used only when the register has no versions at all. */
createFirstVersion(): void {
this.service.createFirstVersion().subscribe((draft) => {
this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
this.reload();
this.edit(draft);
});
}
/** Open the Compare screen for a pending register (checker view). */
openCompare(r: RateRegister, event: Event): void {
event.stopPropagation();
this.preview(r);
}
private formatShort(iso: string | null): string {
if (!iso) {
return '—';
}
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' });
}
}
@@ -0,0 +1,63 @@
/**
* 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/.
*/
export type RegisterStatus = 'Active' | 'Draft' | 'Pending Approval' | 'Expired' | 'Scheduled';
export interface SlabColumn {
key: string;
label: string;
amountUpto: number | null;
}
/** A single rate cell: number for a %, string for a formula ("1% + FD"), null for N.A. */
export type RateCell = number | string | null;
export interface RateChartRow {
srNo: number;
particulars: string;
isFormula?: boolean;
/** GL account this particular is mapped to. null/undefined = "GL not mapped". */
glAccountId?: number | null;
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 interface CibilSlab {
from: number;
to: number;
tier: RiskTier;
type: 'Flat';
adjustmentPct: number;
}
export interface RateRegister {
id: number;
name: string;
effectiveFrom: string;
effectiveTo: string | null;
currency: string;
status: RegisterStatus;
createdBy: string;
lastModified: string;
note?: string;
slabs: SlabColumn[];
rows: RateChartRow[];
cibilSlabs: CibilSlab[];
/** Immutable snapshot of the last-approved state. Compare screen diffs against this. */
baselineRows: RateChartRow[];
baselineCibilSlabs: CibilSlab[];
fallbackNoCibilSlab: number;
fallbackBelowMin: 'use-min-slab' | 'block';
}
@@ -0,0 +1,83 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pr" *ngIf="draft">
<div class="pr__header">
<div class="pr__header-icon">⇄</div>
<div class="pr__header-text">
<h2>Compare changes</h2>
<p>Review the diff before submitting · each changed cell shows OLD → NEW with yellow highlight</p>
</div>
<button mat-stroked-button (click)="backToEdit()">← Back to edit</button>
</div>
<div class="pr__summary">
<div class="pr__summary-item">
<div class="pr__summary-label">Draft</div>
<div class="pr__summary-value">{{ draft.name }}</div>
</div>
<div class="pr__summary-item">
<div class="pr__summary-label">Rate cells changed</div>
<div class="pr__summary-value pr__summary-value--count">{{ changedCellCount() }}</div>
</div>
<div class="pr__summary-item">
<div class="pr__summary-label">CIBIL slabs changed</div>
<div class="pr__summary-value pr__summary-value--count">{{ changedCibilCount() }}</div>
</div>
</div>
<!-- Side-by-side baseline/draft context strip -->
<div class="pr__version-strip">
<div class="pr__version pr__version--old">
<div class="pr__version-pill pr__version-pill--old">OLD · WILL BE DEACTIVATED</div>
<div class="pr__version-name">{{ active?.name ?? 'No active register' }}</div>
<div class="pr__version-sub" *ngIf="active">
Effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}
</div>
</div>
<div class="pr__version-arrow">→</div>
<div class="pr__version pr__version--new">
<div class="pr__version-pill pr__version-pill--new">NEW · WILL BE ACTIVATED</div>
<div class="pr__version-name">{{ draft.name }}</div>
<div class="pr__version-sub">Effective {{ draft.effectiveFrom }} → {{ draft.effectiveTo || '—' }}</div>
</div>
</div>
<mifosx-rate-register-diff
[slabs]="slabs"
[draftRows]="draftRows"
[baselineRows]="baselineRows"
[draftCibil]="draftCibil"
[baselineCibil]="baselineCibil"
></mifosx-rate-register-diff>
<div class="pr__footer">
<button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button>
<div class="pr__footer-note" *ngIf="canSubmit">
Submission will create a maker-checker request · the register becomes effective only after a checker approves it.
</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">
Review-only — this version has already been submitted ({{ draft.status }}).
</div>
<button
*ngIf="canSubmit"
mat-flat-button
color="primary"
class="pr__submit"
(click)="submit()"
[disabled]="submitting || unmappedCount() > 0 || changedCellCount() + changedCibilCount() === 0"
>
<i class="fas fa-paper-plane"></i>
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
</button>
</div>
</div>
@@ -0,0 +1,185 @@
/**
* 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/.
*/
.pr {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__header-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: #ede9fe;
color: #6d28d9;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #0f172a;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__summary {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__summary-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
font-weight: 600;
}
&__summary-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
&--count {
font-size: 20px;
font-weight: 700;
color: #6d28d9;
}
}
// ---- OLD → NEW version strip ------------------------------------------
&__version-strip {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: stretch;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__version {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
border-radius: 8px;
&--old {
background: #fef2f2;
border-left: 3px solid #dc2626;
}
&--new {
background: #f0fdf4;
border-left: 3px solid #059669;
}
}
&__version-pill {
display: inline-block;
width: fit-content;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 3px 10px;
border-radius: 999px;
&--old {
background: #fee2e2;
color: #991b1b;
}
&--new {
background: #d1fae5;
color: #065f46;
}
}
&__version-name {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
&__version-sub {
font-size: 11px;
color: #64748b;
}
&__version-arrow {
align-self: center;
font-size: 24px;
color: #94a3b8;
font-weight: 700;
}
// ---- Footer -----------------------------------------------------------
&__footer {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__footer-note {
flex: 1;
font-size: 12px;
color: #64748b;
}
&__submit {
i {
margin-right: 6px;
}
}
}
@@ -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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
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 { 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.
*
* Shows a single consolidated matrix where every cell renders either its unchanged value or an
* "OLD → NEW" diff with highlight. A "Show only changed rows" toggle filters out rows with no
* changes so the reviewer can focus on what moved.
*
* Submit calls the mock backend; a `rollbackTransaction:true` response means the row has landed
* in the checker inbox and effective activation waits for approval.
*/
@Component({
selector: 'mifosx-preview-register',
templateUrl: './preview-register.component.html',
styleUrls: ['./preview-register.component.scss'],
imports: [
CommonModule,
MatButtonModule,
RateRegisterDiffComponent
]
})
export class PreviewRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
draft!: RateRegister;
active?: RateRegister;
slabs: SlabColumn[] = [];
draftRows: RateChartRow[] = [];
baselineRows: RateChartRow[] = [];
draftCibil: CibilSlab[] = [];
baselineCibil: CibilSlab[] = [];
submitting = false;
/** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */
get canSubmit(): boolean {
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 {
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
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.slabs = this.draft.slabs;
this.draftRows = this.draft.rows;
this.draftCibil = this.draft.cibilSlabs;
// Compare always diffs against a frozen baseline — the currently-Active register's
// last-approved snapshot, OR the draft's own baseline when editing Active in place.
const base = this.draft.id === this.active?.id ? this.draft : (this.active ?? this.draft);
this.baselineRows = base.baselineRows;
this.baselineCibil = base.baselineCibilSlabs;
});
}
changedCellCount(): number {
return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
}
changedCibilCount(): number {
return countChangedCibil(this.draftCibil, this.baselineCibil);
}
/** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */
submit(): void {
if (this.submitting) {
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.service.submitForApproval(this.draft.id).subscribe({
next: (res) => {
this.submitting = false;
const msg = res.rollbackTransaction ? 'Submitted — awaiting checker approval' : 'Changes activated';
this.snack.open(msg, 'OK', { duration: 3500 });
this.router.navigate(['/products/loan-interest-rate-register']);
},
error: (err) => {
// Belt-and-braces guard — the service already catches, but if anything slips through
// we still unlock the UI so the user can retry instead of staring at a spinner forever.
this.submitting = false;
console.error('[PreviewRegister] submit failed', err);
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 });
}
});
}
backToEdit(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } });
}
}
@@ -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)}%`;
}
}
@@ -0,0 +1,647 @@
/**
* 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 { inject, Injectable } from '@angular/core';
import { Observable, catchError, forkJoin, map, of, switchMap, tap } from 'rxjs';
import { ProductsService } from '../products.service';
import {
CibilSlab,
GlOption,
RateCell,
RateChartRow,
RateRegister,
RegisterStatus,
SlabColumn
} from './models/rate-register.model';
/**
* Central Loan Interest Rate Register service.
*
* Data comes from HTTP (/loanproducts/baseinterestrates); there is no built-in sample data. If the
* backend cannot be reached the list is empty apart from local Drafts (not-yet-submitted new
* versions), which live only in memory and are merged in front of server data on every list().
*/
@Injectable({ providedIn: 'root' })
export class RateRegisterService {
private readonly productsService = inject(ProductsService);
/** True while the HTTP backend is reachable. Flipped to false on first HTTP failure (dev mode fallback). */
private useHttp = true;
/** Snapshot of the last list() result — getActive() / getActiveOrFirstDraft() read from this. */
private cache: RateRegister[] = [];
/** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */
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[] = [
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
{ key: 's2', label: 'Upto 25,000', amountUpto: 25000 },
{ key: 's3', label: 'Upto 1,00,000', amountUpto: 100000 },
{ key: 's4', label: 'Upto 2,00,000', amountUpto: 200000 },
{ key: 's5', label: 'Upto 5,00,000', amountUpto: 500000 },
{ key: 's6', label: 'Upto 10,00,000', amountUpto: 1000000 },
{ key: 's7', label: 'Upto 15,00,000', amountUpto: 1500000 },
{ key: 's8', label: 'Upto 20,00,000', amountUpto: 2000000 },
{ key: 's9', label: 'Upto 25,00,000', amountUpto: 2500000 },
{ key: 's10', label: '> 25,00,000', amountUpto: null }
];
// Credit-score bands per the bank's approved register:
// 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.
private readonly cibilSlabs: CibilSlab[] = [
{ from: 0, to: 500, tier: 'V. Poor', type: 'Flat', adjustmentPct: 2.0 },
{ from: 501, to: 600, tier: 'Poor', type: 'Flat', adjustmentPct: 1.5 },
{ from: 601, to: 650, tier: 'Fair', type: 'Flat', adjustmentPct: 1.0 },
{ from: 651, to: 700, tier: 'Good', type: 'Flat', adjustmentPct: 0.5 },
{ from: 701, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: 0 }
];
/**
* The standard loan types every bank offers. Same list the backend seeds into
* m_loan_rate_register_product; used only if that endpoint cannot be reached when the first
* version is created. Any other particular is added by the bank itself.
*/
private readonly defaultParticulars: { srNo: number; name: string }[] = [
{ srNo: 1, name: 'Housing Loan (Purchase/Construction)' },
{ srNo: 2, name: 'Home Repair/Renovation' },
{ srNo: 3, name: 'Personal Loan (Unsecured)' },
{ srNo: 4, name: 'Personal Loan (Secured/Property)' },
{ srNo: 5, name: 'Two Wheeler Loan' },
{ srNo: 6, name: 'Car Loan (New)' },
{ srNo: 7, name: 'Used Car/Vehicle Loan' },
{ srNo: 8, name: 'Commercial Vehicle Loan' },
{ srNo: 9, name: 'Gold Loan' },
{ srNo: 10, name: 'Business Term Loan' },
{ srNo: 11, name: 'Machinery & Equipment Loan' },
{ srNo: 12, name: 'Cash Credit' },
{ srNo: 13, name: 'Overdraft' },
{ srNo: 14, name: 'Agriculture Loan' },
{ srNo: 15, name: 'Loan against FDR' },
{ srNo: 16, name: 'Loan against RD' }
];
/**
* List all registers (local drafts + server versions). If the backend cannot be reached only the
* local drafts are returned: there is no built-in sample data. A successful but empty response is a
* genuinely empty register and shows the "Create first version" state.
*/
list(): Observable<RateRegister[]> {
if (!this.useHttp) {
this.cache = [...this.localDrafts];
return of(this.cache);
}
// Approved versions, the particulars master (each row's GL) and the versions still awaiting a
// checker. A failure of the master or the pending lookup must not hide the approved versions.
return forkJoin({
versions: this.productsService.getLoanBaseInterestRates(),
products: this.productsService.getLoanRateRegisterProducts().pipe(catchError(() => of([]))),
pending: this.productsService.getPendingLoanRateRegisters().pipe(
// The list omits each entry's payload, so fetch the detail of every pending entry.
switchMap((entries: any) => {
const items: any[] = (Array.isArray(entries) ? entries : (entries?.pageItems ?? [])).filter(
(e: any) => (e?.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE'
);
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);
}
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) => {
this.cache = [
...this.localDrafts,
...effectiveVersions
];
}),
map(() => this.cache)
);
}
get(id: number): Observable<RateRegister | undefined> {
const cached = this.cache.find((r) => r.id === id);
if (cached) {
return of(cached);
}
// Local draft lookup (negative ids never hit the server).
if (id < 0) {
return of(this.localDrafts.find((r) => r.id === id));
}
if (!this.useHttp) {
return of(undefined);
}
return this.productsService.getLoanBaseInterestRateById(id).pipe(
map((d: any) => (d ? this.fromBackend(d, new Map()) : undefined)),
catchError(() => of(undefined))
);
}
/** The one currently in force. Used by the Compare screen as the "left-hand" baseline. */
getActive(): RateRegister | undefined {
return this.cache.find((r) => r.status === 'Active');
}
/** The one shown by default on list → edit navigation. */
getActiveOrFirstDraft(): RateRegister | undefined {
return (
this.cache.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Active') ?? this.cache[0]
);
}
getSlabColumns(): SlabColumn[] {
return this.slabs;
}
getCibilSlabs(): CibilSlab[] {
return this.cibilSlabs;
}
// ---- maker-checker (real HTTP when backend is up, mock when not) -----------------
/**
* Submit a Draft for approval. HTTP path POSTs to /loanproducts/baseinterestrates — on a
* maker-checker-gated response ({@code rollbackTransaction=true}) the Draft stays visible as
* "Pending Approval" until the checker acts. On an un-gated response (direct commit) the Draft
* is removed and the register list is re-fetched so the new Active appears.
*/
submitForApproval(id: number): Observable<{ rollbackTransaction: boolean; commandId?: number }> {
const draft = this.localDrafts.find((x) => x.id === id) ?? this.cache.find((x) => x.id === id);
if (!draft) {
return of({ rollbackTransaction: false });
}
const payload = this.toBackend(draft);
return this.productsService.saveLoanBaseInterestRate(payload).pipe(
map((res: any) => {
const rollback = res?.rollbackTransaction === true;
const commandId = res?.commandId;
// Either way the server now owns this version (a pending maker-checker entry, or the new
// Active one), so the local draft goes. list() brings the server copy back on its next call.
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
this.cache = this.cache.filter((c) => c.id !== id);
this.saveDrafts();
return { rollbackTransaction: rollback, commandId };
})
// 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.
);
}
/**
* Checker approves a Pending register — it becomes Active and the previously-Active one is
* deactivated. In real use the Approvals tab calls Fineract's /makercheckers/{id}?command=approve;
* this method remains as a local-only stub for the dev-fallback path.
*/
approve(id: number): Observable<RateRegister | undefined> {
const pending = this.cache.find((x) => x.id === id);
if (!pending || pending.status !== 'Pending Approval') {
return of(undefined);
}
const today = new Date().toISOString().slice(0, 10);
const previouslyActive = this.cache.find((x) => x.status === 'Active');
if (previouslyActive) {
previouslyActive.status = 'Expired';
previouslyActive.effectiveTo = today;
previouslyActive.lastModified = today;
}
pending.status = 'Active';
pending.effectiveFrom = today;
pending.lastModified = today;
pending.baselineRows = this.cloneRows(pending.rows);
pending.baselineCibilSlabs = this.cloneCibilSlabs(pending.cibilSlabs);
// Promote the local draft to the server-shaped view by moving it out of localDrafts.
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
return of(pending);
}
/**
* Scaffold the first-ever Draft. Rows come from the backend's master list of particulars
* (/loanproducts/baseinterestrates/products) with every rate blank, so the bank fills in its own
* 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(): Observable<RateRegister> {
const fromTemplate = (): RateRegister =>
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 draft: RateRegister = {
id: this.nextLocalDraftId(),
name: 'Interest Rate of Loans — new version',
effectiveFrom: today,
effectiveTo: null,
currency: 'INR',
status: 'Draft',
createdBy: 'Current User',
lastModified: today,
note,
slabs: this.slabs,
rows,
cibilSlabs: this.cloneCibilSlabs(cibilTemplate),
// Baseline is blank for a first version, so Compare shows every filled cell as new.
baselineRows: rows.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
rates: new Array(r.rates.length).fill(null)
})),
baselineCibilSlabs: [],
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
};
this.localDrafts.unshift(draft);
this.cache.unshift(draft);
this.saveDrafts();
return draft;
}
/**
* Duplicate the current Active (or any register) as a new local Draft so the user can propose
* changes without touching the live Active. Local Drafts carry a negative id to never collide
* with server-assigned ids.
*/
duplicateAsDraft(sourceId: number): RateRegister | undefined {
const source = this.cache.find((r) => r.id === sourceId);
if (!source) {
return undefined;
}
const newId = this.nextLocalDraftId();
const today = new Date().toISOString().slice(0, 10);
const copy: RateRegister = {
id: newId,
// Left blank on purpose: the maker enters name and dates in the submit popup.
name: '',
effectiveFrom: '',
effectiveTo: null,
currency: source.currency,
status: 'Draft',
createdBy: 'Current User',
lastModified: today,
note: `Draft derived from ${source.name}`,
slabs: source.slabs,
rows: this.cloneRows(source.rows),
cibilSlabs: this.cloneCibilSlabs(source.cibilSlabs),
baselineRows: this.cloneRows(source.rows),
baselineCibilSlabs: this.cloneCibilSlabs(source.cibilSlabs),
fallbackNoCibilSlab: source.fallbackNoCibilSlab,
fallbackBelowMin: source.fallbackBelowMin
};
this.localDrafts.unshift(copy);
this.cache.unshift(copy);
this.saveDrafts();
return copy;
}
/** Checker rejects the pending register — it goes back to Draft (local-only fallback). */
reject(id: number): Observable<RateRegister | undefined> {
const pending = this.cache.find((x) => x.id === id);
if (!pending || pending.status !== 'Pending Approval') {
return of(undefined);
}
pending.status = 'Draft';
pending.lastModified = new Date().toISOString().slice(0, 10);
return of(pending);
}
private nextLocalDraftId(): number {
const minId = this.localDrafts.length ? Math.min(...this.localDrafts.map((d) => d.id)) : 0;
return (minId <= 0 ? minId : 0) - 1;
}
private cloneRows(src: RateChartRow[]): RateChartRow[] {
return src.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
isFormula: r.isFormula,
glAccountId: r.glAccountId ?? null,
rates: [...r.rates]
}));
}
private cloneCibilSlabs(src: CibilSlab[]): CibilSlab[] {
return src.map((s) => ({ ...s }));
}
// ---- backend <-> UI mapping -----------------------------------------------
// Backend sends/receives slabs as a flat list of {productSrNo, slabOrder, cell} rows; the UI
// works with a matrix: SlabColumn[] + RateChartRow[] (34 × 10). The two mappers below bridge
// that gap.
/** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */
private fromBackend(d: any, glBySrNo: Map<number, number | null>): RateRegister {
// Rebuild the slab columns from unique slabOrders.
const slabByOrder = new Map<number, SlabColumn>();
for (const cell of d.slabs ?? []) {
if (!slabByOrder.has(cell.slabOrder)) {
slabByOrder.set(cell.slabOrder, {
key: `s${cell.slabOrder + 1}`,
label: cell.slabLabel,
amountUpto: cell.amountUpto ?? null
});
}
}
const slabs = [...slabByOrder.entries()]
.sort((a, b) => a[0] - b[0])
.map(
([
,
s
]) => s
);
// Group cells by product to build rows.
const rowByProduct = new Map<number, RateChartRow>();
for (const cell of d.slabs ?? []) {
let row = rowByProduct.get(cell.productSrNo);
if (!row) {
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);
}
let value: RateCell;
if (cell.isNa) {
value = null;
} else if (cell.isFormula && cell.rateFormula) {
value = cell.rateFormula;
row.isFormula = true;
} else {
value = cell.rateValue ?? null;
}
row.rates[cell.slabOrder] = value;
}
const rows = [...rowByProduct.values()].sort((a, b) => a.srNo - b.srNo);
const cibilSlabs: CibilSlab[] = (d.cibilSlabs ?? [])
.slice()
.sort((a: any, b: any) => a.displayOrder - b.displayOrder)
.map((c: any) => ({
from: c.scoreFrom,
to: c.scoreTo,
tier: c.tier,
type: c.slabType ?? 'Flat',
adjustmentPct: Number(c.adjustmentPct)
}));
const status: RegisterStatus = d.isActive ? 'Active' : 'Expired';
return {
id: d.id,
name: d.name,
effectiveFrom: d.effectiveFromDate,
effectiveTo: d.effectiveToDate ?? null,
currency: d.currencyCode ?? 'INR',
status,
createdBy: d.createdById != null ? `User #${d.createdById}` : 'System',
lastModified: (d.createdDate ?? d.effectiveFromDate ?? '').toString().slice(0, 10),
note: d.note,
slabs,
rows,
cibilSlabs,
baselineRows: this.cloneRows(rows),
baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs),
fallbackNoCibilSlab: d.fallbackNoCibilSlab ?? 2,
fallbackBelowMin: (d.fallbackBelowMin as 'use-min-slab' | 'block') ?? 'block'
};
}
/** Serialise a UI RateRegister into the JSON shape POST /loanproducts/baseinterestrates expects. */
private toBackend(r: RateRegister): any {
const slabs: any[] = [];
for (const row of r.rows) {
for (let j = 0; j < r.slabs.length; j++) {
const cell = row.rates[j];
const slabCol = r.slabs[j];
const isNa = cell === null;
const isFormula = typeof cell === 'string';
slabs.push({
productSrNo: row.srNo,
productName: row.particulars,
slabOrder: j,
slabLabel: slabCol.label,
amountUpto: slabCol.amountUpto,
rateValue: isNa || isFormula ? null : cell,
rateFormula: isFormula ? cell : null,
isFormula,
isNa
});
}
}
const cibilSlabs = r.cibilSlabs.map((c, i) => ({
scoreFrom: c.from,
scoreTo: c.to,
tier: c.tier,
slabType: c.type,
adjustmentPct: c.adjustmentPct,
displayOrder: i
}));
return {
name: r.name,
effectiveFromDate: r.effectiveFrom,
currencyCode: r.currency,
fallbackNoCibilSlab: r.fallbackNoCibilSlab,
fallbackBelowMin: r.fallbackBelowMin,
note: r.note,
products: r.rows.map((row) => ({
srNo: row.srNo,
name: row.particulars,
glAccountId: row.glAccountId ?? null
})),
slabs,
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 });
}
}
@@ -42,19 +42,168 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
</mat-tab>
@if (isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; LIEN
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-security-lien-step
[loanProductsTemplate]="loanProductsTemplate"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-security-lien-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-advance-limit-tenure-step
[productSubType]="advanceSubType"
></mifosx-loan-product-advance-limit-tenure-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-advance-interest-step
[productSubType]="advanceSubType"
[glAccountId]="productGlAccount"
></mifosx-loan-product-advance-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-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-renewal-closure-step></mifosx-loan-product-renewal-closure-step>
</div>
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-cheque-book-step></mifosx-loan-product-cheque-book-step>
</div>
</mat-tab>
}
}
@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) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm?.get('isLinkedToFloatingInterestRates')"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
</mat-tab>
}
@if (isAdvancedPaymentStrategy) {
<mat-tab>
@@ -90,15 +239,20 @@
</mat-tab>
}
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-terms-step [loanProductsTemplate]="loanProductsTemplate"></mifosx-loan-product-terms-step>
</div>
</mat-tab>
@if (!isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-terms-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-terms-step>
</div>
</mat-tab>
}
-->
<mat-tab>
<ng-template mat-tab-label>
@@ -109,11 +263,12 @@
<mifosx-loan-product-charges-step
[loanProductsTemplate]="loanProductsTemplate"
[currencyCode]="loanProductCurrencyForm.get('currencyCode')"
[multiDisburseLoan]="loanProductSettingsForm.get('multiDisburseLoan')"
[multiDisburseLoan]="loanProductSettingsForm?.get('multiDisburseLoan') ?? multiDisburseFallback"
></mifosx-loan-product-charges-step>
</div>
</mat-tab>
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<ng-template mat-tab-label>
@@ -134,6 +289,7 @@
</div>
</mat-tab>
}
-->
<mat-tab>
<ng-template mat-tab-label>
@@ -13,11 +13,22 @@ import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */
import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component';
import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component';
import { LoanProductSecurityLienStepComponent } from '../loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component';
import { LoanProductAdvanceLimitTenureStepComponent } from '../loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component';
import { LoanProductAdvanceInterestStepComponent } from '../loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component';
import { LoanProductPenalNpaStepComponent } from '../loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component';
import { LoanProductRenewalClosureStepComponent } from '../loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component';
import { LoanProductChequeBookStepComponent } from '../loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component';
import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component';
import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-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 { 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 */
import { ProductsService } from 'app/products/products.service';
@@ -34,7 +45,7 @@ import {
import { Accounting } from 'app/core/utils/accounting';
import { StringEnumOptionData } from '../../../shared/models/option-data.model';
import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component';
import { UntypedFormGroup } from '@angular/forms';
import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component';
import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
@@ -51,12 +62,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent,
LoanProductDetailsStepComponent,
LoanProductCurrencyStepComponent,
LoanProductSettingsStepComponent,
LoanProductInterestRefundStepComponent,
LoanProductPaymentStrategyStepComponent,
LoanProductTermsStepComponent,
LoanProductSecurityLienStepComponent,
LoanProductAdvanceLimitTenureStepComponent,
LoanProductAdvanceInterestStepComponent,
LoanProductPenalNpaStepComponent,
LoanProductRenewalClosureStepComponent,
LoanProductChequeBookStepComponent,
LoanProductLoanSecurityStepComponent,
LoanProductLoanAmountTenureStepComponent,
LoanProductLoanRepaymentStepComponent,
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,
LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductAccountingStepComponent,
LoanProductPreviewStepComponent
]
@@ -73,12 +96,39 @@ export class CreateLoanProductComponent implements OnInit {
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
@ViewChild(LoanProductCurrencyStepComponent, { static: true })
loanProductCurrencyStep: LoanProductCurrencyStepComponent;
// Conditional tabs (Advances A/C only): static false — resolved after the @if renders them.
@ViewChild(LoanProductSecurityLienStepComponent)
loanProductSecurityLienStep: LoanProductSecurityLienStepComponent;
@ViewChild(LoanProductAdvanceLimitTenureStepComponent)
loanProductAdvanceLimitTenureStep: LoanProductAdvanceLimitTenureStepComponent;
@ViewChild(LoanProductAdvanceInterestStepComponent)
loanProductAdvanceInterestStep: LoanProductAdvanceInterestStepComponent;
@ViewChild(LoanProductPenalNpaStepComponent) loanProductPenalNpaStep: LoanProductPenalNpaStepComponent;
@ViewChild(LoanProductRenewalClosureStepComponent)
loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent;
@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. */
readonly multiDisburseFallback = new UntypedFormControl(false);
@ViewChild(LoanProductInterestRefundStepComponent, { static: true })
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
@ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent, { static: true })
// Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically.
@ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent)
loanProductSettingsStep: LoanProductSettingsStepComponent;
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
@ViewChild(LoanProductAccountingStepComponent, { static: true })
@@ -143,7 +193,23 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductTermsForm() {
return this.loanProductTermsStep.loanProductTermsForm;
// Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductTermsStep?.loanProductTermsForm;
}
/** True once the Advance Limit & Tenure step reports an Overdraft (running) facility. */
get isOverdraftFacility(): boolean {
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. */
get advanceSubType(): string {
return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? '';
}
advancePaymentStrategy(value: string) {
@@ -231,47 +297,103 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductSettingsForm() {
return this.loanProductSettingsStep.loanProductSettingsForm;
// Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductSettingsStep?.loanProductSettingsForm;
}
get loanProductAccountingForm() {
return this.loanProductAccountingStep.loanProductAccountingForm;
}
/** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean {
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 {
let tabs = 6; // details, currency, settings, terms, charges, accounting
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5;
if (this.isOverdraftFacility) tabs += 1; // cheque book
} else if (this.isLoanAccount) {
// security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
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
return tabs;
}
get loanProductFormValid() {
if (this.isAdvancedPaymentStrategy) {
const base =
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanIncomeCapitalizationForm.valid &&
this.loanProductAccountingForm.valid
);
} else {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanProductAccountingForm.valid
base &&
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
);
}
// Loans A/C (EMI): the curated Loans steps gate the form; Settings/Terms tabs are commented out.
if (this.isLoanAccount) {
return (
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
);
}
// 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() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
if (this.isAdvanceProduct) {
return this.assembleAdvanceProduct();
}
// 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 = {
...this.loanProductDetailsStep.loanProductDetails,
...this.loanProductCurrencyStep.loanProductCurrency,
...this.loanProductTermsStep.loanProductTerms,
...this.loanProductSettingsStep.loanProductSettings,
...(this.loanProductTermsStep?.loanProductTerms ?? {}),
...(this.loanProductSettingsStep?.loanProductSettings ?? {}),
...this.loanProductChargesStep.loanProductCharges,
...this.loanProductAccountingStep.loanProductAccounting
};
@@ -302,6 +424,340 @@ export class CreateLoanProductComponent implements OnInit {
}
}
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;
}
/**
* Builds the create payload for an Advances A/C product: the curated wizard steps mapped onto Fineract's
* mandatory core fields (so the standard product saves) plus the whitelisted satellite params (persisted by
* LoanProductAdvanceExtService). Only keys the backend accepts are emitted — an unknown key would be
* rejected by checkForUnsupportedParameters.
*/
private assembleAdvanceProduct(): 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 sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
const rc = this.loanProductRenewalClosureStep.loanProductRenewalClosure;
const cb = this.isOverdraftFacility ? this.loanProductChequeBookStep?.loanProductChequeBook : null;
const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
const minTenure = num(lt.minTenure);
const minRate = num(intr.minRate);
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: 1,
// limit -> principal
minPrincipal: num(lt.minLimit),
principal: num(lt.defaultLimit) ?? num(lt.minLimit),
maxPrincipal: num(lt.maxLimit),
// tenure -> repayments (monthly)
minNumberOfRepayments: minTenure,
numberOfRepayments: num(lt.defaultTenure) ?? minTenure,
maxNumberOfRepayments: num(lt.maxTenure),
repaymentEvery: 1,
repaymentFrequencyType: 2, // months
// rate
minInterestRatePerPeriod: 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),
interestRateFrequencyType: 3, // per year
// Fineract mandatory engine defaults for an Advance (no EMI schedule shown to the officer)
amortizationType: 1, // equal installments
interestType: 0, // declining balance
interestCalculationPeriodType: 1, // same as repayment period
transactionProcessingStrategyCode: defaultStrategy,
daysInYearType: 1, // actual
daysInMonthType: 1, // actual
isInterestRecalculationEnabled: false,
// 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,
// ---- satellite params (must match LoanProductAdvanceExtService.ADVANCE_PARAMETERS) ----
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,
facilityType: lt.facilityType,
securityType: sec.securityType,
eligibleAmountBasis: sec.eligibleAmountBasis,
margin: num(sec.margin),
ltv: sec.ltv,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement,
interestBasis: intr.interestBasis,
registerParticular: intr.registerParticular || undefined,
spread: num(intr.spread),
interestChargedOn: intr.interestChargedOn,
interestPostingFrequency: intr.postingFrequency,
penalApplicable: pn.penalApplicable,
penalRate: num(pn.penalRate),
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
interestAfterNpa: pn.interestAfterNpa,
renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays),
preclosureAllowed: rc.preclosureAllowed,
lockInMonths: num(rc.lockInMonths),
interestOnPreclosure: rc.interestOnPreclosure,
chequeBookEnabled: this.isOverdraftFacility,
chequeType: cb?.chequeType,
defaultChequeBookSize: num(cb?.defaultBookSize),
ppsThreshold: num(cb?.ppsThreshold),
ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
// ---- remaining wizard fields ----
// security & lien extras (deposit list joined; rule checkboxes)
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
// limit & tenure extras
repaymentType: lt.repaymentType,
appropriationOrder: lt.appropriationOrder,
debitBalanceAllow: lt.debitBalanceAllow,
allowDebitAfterDueDate: lt.allowDebitTransAfterDueDate,
branchLevelAccountType: lt.branchLevelAccountType,
// interest extras
interestCalcMethod: intr.calcMethod,
interestOnCreditBalance: intr.interestOnCreditBalance,
interestPostingDay: intr.postingDay,
// NPA upgrade rules
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
// cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
chequeBlockNpa: cb?.blockNpa,
chequeBlockLimitExpired: cb?.blockLimitExpired,
chequeBlockOverDp: cb?.blockOverDp,
chequeBlockFrozen: cb?.blockFrozen,
chequeBlockLienReleased: cb?.blockLienReleased
};
// Drop undefined keys so we never send an unresolved value.
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload;
}
submit() {
@@ -0,0 +1,216 @@
/**
* 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/.
*/
/*
* Shared styling for the Advances A/C wizard steps (Security & Lien, Limit & Tenure,
* Interest, Penal & NPA, Renewal & Closure, Cheque Book).
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.adv-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
&__hint {
margin-left: auto;
font-size: 12px;
color: #5f6368;
}
}
.adv-subsection {
padding: 16px 24px;
& + & {
border-top: 1px solid $border;
}
}
.adv-group {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5f6368;
}
.adv-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-grid--3 {
grid-template-columns: repeat(3, 1fr);
@media (width <= 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-subsection .adv-grid {
padding: 0;
}
.adv-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.adv-checks {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 32px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-option {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 10px 14px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.adv-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
margin-top: 2px;
}
}
.adv-info {
padding: 12px 16px;
border-radius: 8px;
background: #f0f9ff;
border: 1px solid #bae6fd;
font-size: 13px;
color: #075985;
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;
}
}
@@ -0,0 +1,98 @@
<!--
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]="loanProductAdvanceInterestForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Rate</h3>
<span class="adv-section__hint">Taken from the Loan Interest Rate Register</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>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-sliders-h"></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) {
<mat-option [value]="opt">{{ opt }}</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) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Charged On</mat-label>
<mat-select formControlName="interestChargedOn">
@for (opt of chargedOnOptions; 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 Credit Balance</mat-label>
<mat-select formControlName="interestOnCreditBalance">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Posting</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Frequency</mat-label>
<mat-select formControlName="postingFrequency">
@for (opt of postingFrequencyOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Day</mat-label>
<mat-select formControlName="postingDay">
@for (opt of postingDayOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</form>
@@ -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/.
*/
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
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,
* daily calculation and monthly posting. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-interest-step',
templateUrl: './loan-product-advance-interest-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RegisterRateViewComponent
]
})
export class LoanProductAdvanceInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder);
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 = [
{ id: 'deposit_spread', label: 'Deposit Rate + Spread' },
{ id: 'fixed', label: 'Fixed rate' },
{ id: 'floating', label: 'Floating (EBLR-linked)' }
];
readonly calcMethodOptions = [
{ id: 'daily', label: 'Daily product (daily outstanding)' },
{ id: 'monthly', label: 'Monthly average balance' }
];
readonly daysInYearOptions = [
'Actual (365 / 366)',
'365',
'360'
];
readonly daysInMonthOptions = [
'Actual',
'30'
];
readonly chargedOnOptions = [
{ id: 'utilised', label: 'Utilised (debit) balance' },
{ id: 'limit', label: 'Sanctioned limit' }
];
readonly postingFrequencyOptions = [
'Monthly',
'Quarterly',
'Half-yearly',
'At Maturity'
];
readonly postingDayOptions = [
'Last day of month',
'1st of month',
'Anniversary day'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceInterestForm = this.formBuilder.group({
interestBasis: ['deposit_spread'],
spread: [
2.0,
[
Validators.min(0),
Validators.max(20)
]
],
// Filled from the register row; never typed in.
minRate: [''],
maxRate: [''],
defaultRate: [''],
// The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created.
registerParticular: [
'',
Validators.required
],
calcMethod: ['daily'],
daysInYear: ['Actual (365 / 366)'],
daysInMonth: ['Actual'],
interestChargedOn: ['utilised'],
interestOnCreditBalance: ['NO'],
postingFrequency: ['Monthly'],
postingDay: ['Last day of month']
});
}
/**
* 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 {
return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread';
}
get loanProductAdvanceInterest() {
return this.loanProductAdvanceInterestForm.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]="loanProductAdvanceLimitTenureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-layer-group"></i>
<h3>Facility Type</h3>
</div>
<mat-radio-group formControlName="facilityType" class="adv-type-options">
<mat-radio-button value="demand" class="adv-type-option">
<span class="adv-type-option__label">Demand Loan</span>
<span class="adv-type-option__hint">Paid out once; repaid at maturity. No cheque book.</span>
</mat-radio-button>
<mat-radio-button value="overdraft" class="adv-type-option">
<span class="adv-type-option__label">Overdraft (Running)</span>
<span class="adv-type-option__hint">Draw and repay any time within the limit. Cheque book allowed.</span>
</mat-radio-button>
</mat-radio-group>
<div style="padding: 16px 24px 20px">
<div class="adv-info">
This choice decides whether the <strong>Cheque Book</strong> step appears. CC / OD sub types are always running.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-coins"></i>
<h3>Limit</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Limit (₹)</mat-label>
<input matInput type="number" formControlName="minLimit" min="0" required />
<mat-error>Minimum Limit is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Limit (₹)</mat-label>
<input matInput type="number" formControlName="defaultLimit" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Limit (₹)</mat-label>
<input matInput type="number" formControlName="maxLimit" min="0" required />
<mat-error>Maximum Limit is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Tenure &amp; Repayment</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]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: 12–15 months only</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
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]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
}
</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]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
} @else {
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repayment Type</mat-label>
<mat-select formControlName="repaymentType">
@for (opt of repaymentTypeOptions; 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 adv-field--full">
<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>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-toggle-on"></i>
<h3>Account Behaviour</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Debit Balance Allow</mat-label>
<mat-select formControlName="debitBalanceAllow">
@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>Allow Debit Trans After Due Date</mat-label>
<mat-select formControlName="allowDebitTransAfterDueDate">
@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>Branch Level A/c Type</mat-label>
<mat-select formControlName="branchLevelAccountType">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>EMI fields (Number of Repayments, Repaid Every, Variable Installments) are hidden for an Advance.</span>
</div>
</form>
@@ -0,0 +1,171 @@
/**
* 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, Input } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Limit & Tenure step for an Advances A/C product (Figma step 4).
*
* Facility Type here decides the rest of the flow: an Overdraft (running) facility gets a cheque book,
* a Demand Loan does not. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-limit-tenure-step',
templateUrl: './loan-product-advance-limit-tenure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductAdvanceLimitTenureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceLimitTenureForm: UntypedFormGroup;
/**
* Product sub type picked on the Details step. "Loan against FD" is bank-policy capped to a
* 12–15 month tenure, so the officer cannot create an FD product outside that band. Other
* advance sub types (Gold, RD, CC, …) stay open.
*/
private _productSubType = '';
@Input()
set productSubType(value: string) {
const next = value || '';
if (next === this._productSubType) {
return;
}
this._productSubType = next;
this.applyTenurePolicy();
}
get productSubType(): string {
return this._productSubType;
}
readonly repaymentTypeOptions = [
{ id: 'bullet', label: 'Bullet – principal + interest at maturity' },
{ id: 'running', label: 'Running – any time; clear by limit expiry' }
];
readonly appropriationOrderOptions = [
{ id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' },
{ id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceLimitTenureForm = this.formBuilder.group({
facilityType: ['demand'],
minLimit: [
'',
Validators.required
],
defaultLimit: [''],
maxLimit: [
'',
Validators.required
],
minTenure: [
12,
[
Validators.required,
Validators.min(1)
]
],
defaultTenure: [12],
maxTenure: [
15,
[
Validators.required,
Validators.min(1)
]
],
repaymentType: ['bullet'],
appropriationOrder: ['charges_penal_interest_principal'],
debitBalanceAllow: ['YES'],
allowDebitTransAfterDueDate: ['NO'],
branchLevelAccountType: ['NO']
});
}
/** Read by the wizard to decide whether the Cheque Book step applies. */
get isOverdraft(): boolean {
return this.loanProductAdvanceLimitTenureForm.get('facilityType').value === 'overdraft';
}
get loanProductAdvanceLimitTenure() {
return this.loanProductAdvanceLimitTenureForm.getRawValue();
}
/** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */
get isFdTenureLocked(): boolean {
return this._productSubType === 'fd';
}
get tenureMin(): number {
return this.isFdTenureLocked ? 12 : 1;
}
get tenureMax(): number | null {
return this.isFdTenureLocked ? 15 : null;
}
/**
* FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1).
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
* match the selected product.
*/
private applyTenurePolicy(): void {
const form = this.loanProductAdvanceLimitTenureForm;
if (!form) {
return;
}
const min = form.get('minTenure');
const def = form.get('defaultTenure');
const max = form.get('maxTenure');
if (this._productSubType === 'fd') {
const bounds = [
Validators.min(12),
Validators.max(15)
];
min.setValidators([
Validators.required,
...bounds
]);
def.setValidators(bounds);
max.setValidators([
Validators.required,
...bounds
]);
min.setValue(12, { emitEvent: false });
def.setValue(12, { emitEvent: false });
max.setValue(15, { emitEvent: false });
} else {
min.setValidators([
Validators.required,
Validators.min(1)
]);
def.setValidators([Validators.min(1)]);
max.setValidators([
Validators.required,
Validators.min(1)
]);
}
min.updateValueAndValidity({ emitEvent: false });
def.updateValueAndValidity({ emitEvent: false });
max.updateValueAndValidity({ emitEvent: false });
}
}
@@ -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]="loanProductChequeBookForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-money-check"></i>
<h3>Cheque Book Facility</h3>
<span class="adv-section__hint">Overdraft (running) only</span>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Type</mat-label>
<mat-select formControlName="chequeType">
@for (opt of chequeTypeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Book Sizes Allowed (leaves)</mat-label>
<mat-select formControlName="bookSizes" multiple>
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Book Size (leaves)</mat-label>
<mat-select formControlName="defaultBookSize">
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@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>Free Cheque Books per Year</mat-label>
<input matInput type="number" formControlName="freeBooksPerYear" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Name Printed on Cheque</mat-label>
<mat-select formControlName="namePrinted">
@for (opt of namePrintedOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-shield-halved"></i>
<h3>Validation &amp; Security</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Positive Pay (PPS) Threshold (₹)</mat-label>
<input matInput type="number" formControlName="ppsThreshold" min="0" required />
<mat-hint>Issuer confirms cheques at or above this</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PPS Mandatory Above (₹)</mat-label>
<input matInput type="number" formControlName="ppsMandatoryAbove" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Cheque Validity</mat-label>
<input matInput value="3 months (RBI)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Stop Payment Allowed</mat-label>
<mat-select formControlName="stopPaymentAllowed">
@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>Maintain Cheque Stock Register</mat-label>
<mat-select formControlName="maintainStockRegister">
@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>Allow Withdrawal Slip</mat-label>
<mat-select formControlName="allowWithdrawalSlip">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-ban"></i>
<h3>Block Cheque Issue &amp; Clearing When</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="blockNpa">Account is NPA or out of order</mat-checkbox>
<mat-checkbox formControlName="blockLimitExpired">Limit has expired (renewal due)</mat-checkbox>
<mat-checkbox formControlName="blockOverDp">Debit would exceed the limit / drawing power</mat-checkbox>
<mat-checkbox formControlName="blockFrozen">Account is frozen or inoperative</mat-checkbox>
<mat-checkbox formControlName="blockLienReleased">
Pledged deposit has matured or its lien was released
</mat-checkbox>
</div>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>Cheque book fees (issue, per leaf, return, stop payment) are set on the Charges step.</span>
</div>
</form>
@@ -0,0 +1,92 @@
/**
* 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, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Cheque Book step (Figma step 8), shown only when the Facility Type is Overdraft (running).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-cheque-book-step',
templateUrl: './loan-product-cheque-book-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductChequeBookStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductChequeBookForm: UntypedFormGroup;
readonly chequeTypeOptions = [
'CTS-2010 · Payable at par (multi-city)',
'CTS-2010 · Local',
'Non-CTS'
];
readonly bookSizeOptions = [
'10',
'25',
'50',
'100'
];
readonly namePrintedOptions = [
'Account holder name',
'Firm / entity name',
'As per instruction'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductChequeBookForm = this.formBuilder.group({
chequeType: ['CTS-2010 · Payable at par (multi-city)'],
bookSizes: [
[
'10',
'25',
'50'
]
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
Validators.required,
Validators.min(0)
]
],
ppsMandatoryAbove: [500000],
stopPaymentAllowed: ['YES'],
maintainStockRegister: ['YES'],
allowWithdrawalSlip: ['NO'],
blockNpa: [true],
blockLimitExpired: [true],
blockOverDp: [true],
blockFrozen: [true],
blockLienReleased: [true]
});
}
get loanProductChequeBook() {
return this.loanProductChequeBookForm.getRawValue();
}
}
@@ -7,178 +7,190 @@
-->
<form [formGroup]="loanProductDetailsForm">
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-calendar-alt"></i>
<h3>{{ 'labels.inputs.Availability' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<!-- Interest Information -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-percentage"></i>
<h3>Interest Information</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Product Classification + GL Account — combined section -->
<!-- Product Type — picked first; it decides which sections the rest of the wizard shows -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-sitemap"></i>
<h3>Product Classification</h3>
<h3>Product Type</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Product Type Group</mat-label>
<mat-select formControlName="productTypeGroup">
@for (group of productTypeGroups; track group.id) {
<mat-option [value]="group.id">{{ group.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-radio-group formControlName="productTypeGroup" class="lpd-type-options">
@for (group of productTypeGroups; track group.id) {
<mat-radio-button [value]="group.id" class="lpd-type-option">
<span class="lpd-type-option__label">{{ group.label }}</span>
<span class="lpd-type-option__hint">{{ group.hint }}</span>
</mat-radio-button>
}
</mat-radio-group>
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label>
<mat-select formControlName="glAccount">
@for (acc of availableGlAccounts; track acc.id) {
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option>
<mat-label>Product Sub Type</mat-label>
<mat-select formControlName="productTypeSubGroup" [required]="!isExistingProduct">
@for (subGroup of availableSubGroups; track subGroup.id) {
<mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
}
</mat-select>
<mat-error>GL Account is required</mat-error>
<mat-error>Product Sub Type is <strong>required</strong></mat-error>
</mat-form-field>
}
</div>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account (Asset)</mat-label>
<mat-select
formControlName="glAccount"
[required]="!isExistingProduct"
matTooltip="Ledger GL of this product. Account numbers of its loan accounts are generated from this GL."
>
@for (acc of assetAccountOptions; track acc.id) {
<mat-option [value]="acc.id">({{ acc.glCode }}) {{ acc.name }}</mat-option>
}
</mat-select>
<mat-error>GL Account is <strong>required</strong></mat-error>
</mat-form-field>
</div>
}
</div>
@if (!isProductTypeChosen) {
<div class="lpd-type-pending">
<i class="fas fa-circle-info"></i>
Select a Product Type and Sub Type to continue.
</div>
}
@if (isProductTypeChosen) {
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
[maxlength]="isExistingProduct ? 4 : null"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
(input)="normalizeShortName($event)"
/>
@if (loanProductDetailsForm.get('shortName').hasError('pattern')) {
<mat-error>Short Name must be exactly <strong>2 capital letters</strong> (A–Z)</mat-error>
} @else {
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--row-start" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="startDateMin"
[max]="startDateMax"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="closeDateMin"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-flag"></i>
<h3>Regulatory Classification</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Loan Product Category (RBI)</mat-label>
<mat-select formControlName="loanProductCategory">
@for (opt of loanProductCategoryOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Priority Sector</mat-label>
<mat-select formControlName="prioritySector">
@for (opt of prioritySectorOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>CIBIL Account Type</mat-label>
<mat-select formControlName="cibilAccountType">
@for (opt of cibilAccountTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Key Fact Statement (KFS)</mat-label>
<mat-select formControlName="kfsEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>APR Calculation</mat-label>
<mat-select formControlName="aprEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form>
@@ -61,6 +61,57 @@ $gray-900: #202124;
&--full {
grid-column: 1 / -1;
}
// Starts a new row, so Start / Close Date sit together instead of one of them beside Short Name.
&--row-start {
grid-column-start: 1;
}
}
// Product Type — first section
.lpd-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lpd-type-option {
border: 1px solid $border;
border-radius: 8px;
padding: 8px 12px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.lpd-type-pending {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
}
}
.lpd-checkbox-row {

Some files were not shown because too many files have changed in this diff Show More