Compare commits

..
7 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
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
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
180 changed files with 13151 additions and 5523 deletions
-5
View File
@@ -85,11 +85,6 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts/maturity-dashboard'); return this.http.get('/fixeddepositaccounts/maturity-dashboard');
} }
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> { getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams() let httpParams = new HttpParams()
.set('paged', 'true') .set('paged', 'true')
-2
View File
@@ -64,7 +64,6 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module'; import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module'; import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module'; import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module'; import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module'; import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module'; import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -179,7 +178,6 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule, DeafModule,
SavingsDailyInterestModule, SavingsDailyInterestModule,
FdDashboardModule, FdDashboardModule,
RdDashboardModule,
DicgcModule, DicgcModule,
FiuModule, FiuModule,
GlReportsModule, GlReportsModule,
@@ -51,12 +51,6 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {}); return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
} }
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> { approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {}); return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
} }
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs'; import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@@ -3426,7 +3426,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate, meetingDate: shareRow.meetingDate,
description: shareRow.description || null, description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId, settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot settlementRowSnapshot
}; };
@@ -3457,60 +3456,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.' ? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.' : 'Pending Manager approval — redemption and settlement both post together on approval.'
]; ];
this.showShareWithdrawalConfirm(payload, confirmLines, () => this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo) this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
); );
} }
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/** /**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the * Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the * member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3622,7 +3572,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.' ? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.' : 'Pending Manager approval — redemption and settlement both post together on approval.'
]; ];
this.showShareWithdrawalConfirm(payload, confirmLines, () => this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo) this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
); );
} }
@@ -5246,8 +5196,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('error', 'Invalid Account Type', [ this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`, `Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.', 'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.', 'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
'RD installments are paid through a cashier Receipt or Auto Debit.'
]); ]);
return; return;
} }
@@ -5301,19 +5250,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 && this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer') this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) { ) {
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
// Receipt or the RD's own Auto Debit.
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
if (rdRows.length > 0) {
this.showAlert('error', 'Recurring Deposit Not Allowed', [
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
]);
return;
}
const drRows = this.rows.filter((r) => r.dbCr === 'Dr'); const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr'); const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -129,31 +129,6 @@
<span>* Date of Meeting</span> <span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" /> <input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label> </label>
<label class="inv-form-field">
<span>* Settlement Destination</span>
@if (!isReview) {
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
<option value="SAVINGS">Member's Linked Savings Account</option>
<option value="GL">GL Account</option>
</select>
} @else {
<input
type="text"
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
readonly
/>
}
</label>
@if (!isReview && settlementMode === 'GL') {
<label class="inv-form-field">
<span>* Settlement GL Account</span>
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
></mifosx-gl-account-selector>
</label>
}
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Settlement Payment Type</span> <span>* Settlement Payment Type</span>
@if (!isReview) { @if (!isReview) {
@@ -411,25 +386,14 @@
<td>Date of Meeting</td> <td>Date of Meeting</td>
<td>{{ meetingDate }}</td> <td>{{ meetingDate }}</td>
</tr> </tr>
<tr>
<td>Settlement Destination</td>
<td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
</tr>
@if (settlementMode === 'GL') {
<tr>
<td>Settlement GL Account</td>
<td>{{ selectedSettlementGlName }}</td>
</tr>
} @else {
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
}
<tr> <tr>
<td>Settlement Payment Type</td> <td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td> <td>{{ settlementPaymentTypeName }}</td>
</tr> </tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody> </tbody>
</table> </table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule, UntypedFormControl } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -19,9 +19,6 @@ import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service'; import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service'; import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component'; import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AccountingService } from '../../accounting/accounting.service';
import { GLAccount } from '../../shared/models/general.model';
export type ShareWdMode = 'create' | 'review'; export type ShareWdMode = 'create' | 'review';
@@ -54,8 +51,7 @@ interface ActivityEntry {
RouterModule, RouterModule,
MatIconModule, MatIconModule,
MatSnackBarModule, MatSnackBarModule,
SearchSelectComponent, SearchSelectComponent
GlAccountSelectorComponent
] ]
}) })
export class ShareWithdrawalRequestComponent implements OnInit { export class ShareWithdrawalRequestComponent implements OnInit {
@@ -64,7 +60,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService); private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService); private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService); private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode /** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
@@ -96,17 +91,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
paymentTypes: any[] = []; paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null; settlementPaymentTypeId: number | null = null;
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
* here is still fine: the backend recognises it and books straight to Sundry with no further
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
settlementGlAccountId: number | null = null;
glAccountsData: GLAccount[] = [];
toGlAccountControl = new UntypedFormControl('');
activityLog: ActivityEntry[] = []; activityLog: ActivityEntry[] = [];
reviewRequest: any = null; reviewRequest: any = null;
showRejectDialog = false; showRejectDialog = false;
@@ -148,17 +132,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
return this.selectedSettlementPaymentType?.name || '—'; return this.selectedSettlementPaymentType?.name || '—';
} }
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
* the raw id while the GL account list is still loading. */
get selectedSettlementGlName(): string {
if (!this.settlementGlAccountId) {
return '—';
}
return (
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
);
}
get isFormValid(): boolean { get isFormValid(): boolean {
if (!this.account) return false; if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false; if (!this.requestedShares || this.requestedShares <= 0) return false;
@@ -168,7 +141,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
if (!this.resolutionNumber.trim()) return false; if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false; if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false; if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true; return true;
} }
@@ -188,18 +160,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held, pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held` detail: `${shares} of ${held} shares held`
}, },
this.settlementMode === 'GL' {
? { label: 'Linked savings account confirmed',
label: 'Settlement GL Account selected', pass: !!this.account?.savingsAccountNumber,
pass: !!this.settlementGlAccountId, detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing' },
}
: {
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail:
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{ {
label: 'Settlement Payment Type selected (non-cash)', label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—', pass: this.settlementPaymentTypeName !== '—',
@@ -249,37 +214,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([]))) .pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? [])); .subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') { if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId); this.loadReviewRequest(this.requestId);
} }
} }
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
* stale GL id can never ride along on a SAVINGS submission. */
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
this.settlementMode = mode;
if (mode === 'GL') {
this.loadGlAccounts();
} else {
this.settlementGlAccountId = null;
this.toGlAccountControl.setValue('');
}
}
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
onAccountSelected(accountId: string): void { onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId; this.selectedAccountId = accountId;
if (!accountId) { if (!accountId) {
@@ -307,11 +246,6 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? ''; this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate; this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? ''; this.description = req.description ?? '';
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
if (this.settlementMode === 'GL') {
this.loadGlAccounts();
}
if (req.activity_log) { if (req.activity_log) {
try { try {
@@ -355,9 +289,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(), resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate, meetingDate: this.meetingDate,
description: this.description, description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId, settlementPaymentTypeId: this.settlementPaymentTypeId
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
}; };
this.wdService.submit(payload).subscribe({ this.wdService.submit(payload).subscribe({
next: () => { next: () => {
@@ -321,7 +321,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
this.availableFys = [...fys].sort().reverse(); this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) { if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? ''; this.fy =
this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
} }
} }
@@ -367,9 +368,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
: 0; : 0;
} }
// What was actually debited for this customer as deductee, wherever the savings account sits. // What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0);
.filter((a) => a.meta.tdsApplicable)
.reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth || this.configMissing) { if (!this.dateOfBirth || this.configMissing) {
return; return;
@@ -591,13 +591,16 @@
<i class="fa fa-calendar section-header__icon"></i> <i class="fa fa-calendar section-header__icon"></i>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3> <h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div> </div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div> </div>
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or @if (!showClosedRecurringAccounts) {
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
<div class="table-wrapper"> <div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'"> <table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'">
<ng-container matColumnDef="Account No"> <ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
@@ -611,15 +614,6 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text> <mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active"> <ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th> <th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td> <td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -644,7 +638,7 @@
<i class="fa fa-check"></i> <i class="fa fa-check"></i>
</button> </button>
} }
@if (element.status.approved) { @if (!element.status.submittedAndPendingApproval && !element.status.active) {
<button <button
class="account-action-button" class="account-action-button"
mat-raised-button mat-raised-button
@@ -670,7 +664,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="no-data"> <ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length"> <td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<div class="table-empty-state"> <div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i> <i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text"> <p class="table-empty-state__text">
@@ -680,17 +674,67 @@
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr> <tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr <tr
mat-row mat-row
*matRowDef="let row; columns: recurringColumns" *matRowDef="let row; columns: openSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']" [routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row" class="select-row"
></tr> ></tr>
<tr <tr
mat-footer-row mat-footer-row
*matFooterRowDef=" *matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : [] (savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
</div>
}
<!-- Closed Recurring Deposit Accounts -->
@if (showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: closedSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
" "
></tr> ></tr>
</table> </table>
@@ -419,24 +419,3 @@ table[mat-table] {
font-size: 24px; font-size: 24px;
z-index: 1; z-index: 1;
} }
.rd-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: #dcfce7;
color: #166534;
&--ended {
background: #e2e8f0;
color: #475569;
}
}
.rd-status__date {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
@@ -187,15 +187,6 @@ export class GeneralTabComponent implements OnDestroy {
'Saving Account', 'Saving Account',
'Closed Date' 'Closed Date'
]; ];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Status',
'Last Active',
'Balance',
'Actions'
];
/** Open Shares Accounts Columns */ /** Open Shares Accounts Columns */
openSharesColumns: string[] = [ openSharesColumns: string[] = [
'Account No', 'Account No',
@@ -484,30 +475,6 @@ export class GeneralTabComponent implements OnDestroy {
); );
} }
/** Where a recurring deposit stands - for one that has ended, how it ended. */
rdStatusLabel(account: any): string {
switch (account?.status?.code) {
case 'savingsAccountStatusType.active':
return 'Active';
case 'savingsAccountStatusType.matured':
return 'Matured (not closed)';
case 'savingsAccountStatusType.submitted.and.pending.approval':
return 'Pending Approval';
case 'savingsAccountStatusType.approved':
return 'Approved';
case 'savingsAccountStatusType.closed':
return 'Matured & Closed';
case 'savingsAccountStatusType.pre.mature.closure':
return 'Premature Closed';
case 'savingsAccountStatusType.rejected':
return 'Rejected';
case 'savingsAccountStatusType.withdrawn.by.applicant':
return 'Withdrawn by Client';
default:
return account?.status?.value ?? '';
}
}
viewAccountsLabel(closed: boolean): string { viewAccountsLabel(closed: boolean): string {
if (closed) { if (closed) {
return 'labels.buttons.View Active Accounts'; return 'labels.buttons.View Active Accounts';
@@ -377,8 +377,6 @@
</button> </button>
} }
@if (selectedTabIndex < totalTabs - 1) { @if (selectedTabIndex < totalTabs - 1) {
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid"> <button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) { @if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner> <mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
@@ -392,7 +390,6 @@
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon> <fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
} }
</button> </button>
</span>
} }
@if (selectedTabIndex === totalTabs - 1) { @if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']"> <button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,12 +393,6 @@ export class EditClientComponent {
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) { if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.'; return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
} }
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
const labels = this.invalidGeneralFieldLabels();
if (labels.length) {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (this.selectedTabIndex === 1) { if (this.selectedTabIndex === 1) {
const labels = this.clientKycStep?.invalidFieldLabels || []; const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) { if (labels.length) {
@@ -414,42 +408,6 @@ export class EditClientComponent {
return 'Please fill all mandatory fields on this tab to continue.'; return 'Please fill all mandatory fields on this tab to continue.';
} }
/**
* Readable names of the General-tab controls that are invalid. Older clients can be missing
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
* on screen saying why — this feeds the button tooltip.
*/
private invalidGeneralFieldLabels(): string[] {
const labels: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
genderId: 'Gender',
mobileNo: 'Mobile No',
alternateMobileNo: 'Alternate Mobile No',
emailAddress: 'Email',
dateOfBirth: 'Date of Birth',
submittedOnDate: 'Submitted On',
activationDate: 'Activation Date',
savingsProductId: 'Savings Product',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Name',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
monthlyTurnover: 'Monthly Turnover',
annualTurnover: 'Yearly Turnover',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
guardianDetails: 'Guardian',
clientNonPersonDetails: 'Entity Details'
};
const controls = this.clientGeneralForm?.controls || {};
return Object.keys(controls)
.filter((name) => controls[name].invalid)
.map((name) => labels[name] || name);
}
/** Whether the current tab has unsaved edits */ /** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean { isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true; return this.tabEditMode[this.selectedTabIndex] === true;
+1 -6
View File
@@ -48,12 +48,7 @@ export class CacheInterceptor implements HttpInterceptor {
'/cashreconciliation', '/cashreconciliation',
'/cashsettlement', '/cashsettlement',
'/cashtransfer', '/cashtransfer',
'/branchday', '/branchday'
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
]; ];
private isCacheable(url: string): boolean { private isCacheable(url: string): boolean {
+5 -27
View File
@@ -67,18 +67,12 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// endpoint isn't available on a given backend deployment, the client-general-step // endpoint isn't available on a given backend deployment, the client-general-step
// component already falls back to riskRules = [] silently; no toast needed here either. // component already falls back to riskRules = [] silently; no toast needed here either.
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules'); const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
// A document's own file can be missing from the content store (a database copied between
// environments without its file store is the usual cause) even though its m_document row
// survives — the backend reports that as a 404, same as a client with no photo, and every
// caller that fetches a signature/attachment image already recovers with its own catchError.
const isDocumentAttachmentRequest = request.url.match(/\/documents\/\d+\/attachment$/);
const isSilentRequest = const isSilentRequest =
isClientImageRequest || isClientImageRequest ||
isClientKycRequest || isClientKycRequest ||
isDirectorRequest || isDirectorRequest ||
isClientPendingEditRequest || isClientPendingEditRequest ||
isKycRiskRulesRequest || isKycRiskRulesRequest;
isDocumentAttachmentRequest;
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose // Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
// component already recovers via its own catchError) — silenced for every status, since // component already recovers via its own catchError) — silenced for every status, since
@@ -99,8 +93,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response; throw response;
} }
// Resolved once and emitted in one place below — see the severity decision there for which of // Resolved once and emitted in one place below, so every failure here is consistently tagged
// these surfaces as a centred modal versus a corner toast. // as an error and therefore surfaces as a centred modal rather than a corner toast.
let alert: { type: string; message: string } | null = null; let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) { if (status === 401 || (environment.oauth.enabled && status === 400)) {
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' }; alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
@@ -125,13 +119,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
message: errorMessage || 'This action is not allowed in the current state.' message: errorMessage || 'This action is not allowed in the current state.'
}; };
} else if (status === 500) { } else if (status === 500) {
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage) alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
// when one is present; the generic sentence is only a last resort for a response with no body
// at all (a proxy timeout, a stack trace the server never wrapped into JSON).
alert = {
type: 'Internal Server Error',
message: errorMessage || 'Internal Server Error. Please try again later.'
};
} else if (status === 501) { } else if (status === 501) {
alert = { alert = {
type: this.translate.instant('error.resource.notImplemented.type'), type: this.translate.instant('error.resource.notImplemented.type'),
@@ -145,17 +133,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
} }
if (alert) { if (alert) {
// A GET is the page reading data it did not ask the user for, in the background — a photo, this.alertService.alert({ ...alert, severity: 'error' });
// an attachment, a notes list, a standing-instructions panel. Every one of those already has
// its own catchError fallback at the call site (see transaction-entry.component.ts and
// similar account-overview loaders), so the page keeps working regardless. Blocking the whole
// screen on a dialog the user has to click "OK" on, for data that was never more than a
// secondary panel, is the "irritate na ho" complaint this guards against — it goes to the
// corner toast instead, same place a non-error alert already goes. A write (POST/PUT/PATCH/
// DELETE) is different: that is the one action the user was waiting on, so its failure still
// needs the centred, must-acknowledge dialog.
const isBackgroundRead = request.method === 'GET' && (status === 500 || status === 501 || status === 503);
this.alertService.alert({ ...alert, severity: isBackgroundRead ? undefined : 'error' });
} }
throw response; throw response;
-3
View File
@@ -1220,9 +1220,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']"> <button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard <fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button> </button>
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
</button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report <fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
</button> </button>
@@ -20,7 +20,9 @@
<div class="t-live"></div> <div class="t-live"></div>
&nbsp;LIVE &nbsp;LIVE
</div> </div>
<div class="t-chip">Branch:&nbsp;<strong>{{ officeLabel }}</strong></div> <div class="t-chip">
Branch:&nbsp;<strong>{{ officeLabel }}</strong>
</div>
<div class="t-chip"> <div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong> Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div> </div>
@@ -195,13 +195,6 @@ export class CreateRecurringDepositsAccountComponent {
}); });
return; return;
} }
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
this.alertService.alert({
type: 'Error',
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
});
return;
}
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) { if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
this.alertService.alert({ this.alertService.alert({
type: 'Error', type: 'Error',
@@ -216,39 +209,6 @@ export class CreateRecurringDepositsAccountComponent {
}); });
return; return;
} }
// Same limits the server enforces - checked here so the message names the numbers before anything is sent.
const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.`
});
return;
}
if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.`
});
return;
}
if (
this.recurringDepositsAccountCurrencyStep?.autoDebitFacility &&
detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS'
) {
this.alertService.alert({
type: 'Error',
message:
'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.'
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) { if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' }); this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return; return;
@@ -295,16 +255,8 @@ export class CreateRecurringDepositsAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false, isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false, isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false, allowWithdrawal: pt?.allowWithdrawal ?? false,
// Money paid ahead of a due date counts towards the next installments. Without this an advance cash adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
// payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// a second time and the missed-installment penalty charged it. The RD product form has no such option, so
// the product's value was always false.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
}; };
// Charges from product template // Charges from product template
@@ -233,11 +233,6 @@ export class EditRecurringDepositAccountComponent {
}); });
return; return;
} }
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) { if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' }); this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return; return;
@@ -280,13 +275,8 @@ export class EditRecurringDepositAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false, isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false, isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false, allowWithdrawal: pt?.allowWithdrawal ?? false,
// See create-recurring-deposits-account: advance payments must count towards the next installments. adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
adjustAdvanceTowardsFuturePayments: true, autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
}; };
const rawCharges: any[] = pt?.charges ?? []; const rawCharges: any[] = pt?.charges ?? [];
@@ -70,9 +70,8 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit {
*/ */
createActivateRecurringDepositsAccountForm() { createActivateRecurringDepositsAccountForm() {
this.activateRecurringDepositsAccountForm = this.formBuilder.group({ this.activateRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally activated on.
activatedOnDate: [ activatedOnDate: [
this.settingsService.businessDate, '',
Validators.required Validators.required
] ]
}); });
@@ -72,9 +72,8 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
*/ */
createApproveRecurringDepositsAccountForm() { createApproveRecurringDepositsAccountForm() {
this.approveRecurringDepositsAccountForm = this.formBuilder.group({ this.approveRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally approved on.
approvedOnDate: [ approvedOnDate: [
this.settingsService.businessDate, '',
Validators.required Validators.required
], ],
note: [''] note: ['']
@@ -13,16 +13,6 @@
<div class="layout-column"> <div class="layout-column">
<h3 class="mat-h3 flex-fill">{{ title }}</h3> <h3 class="mat-h3 flex-fill">{{ title }}</h3>
@if (totalLienAmount > 0) {
<p class="lien-warn">
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()"> <mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input <input
@@ -164,7 +154,7 @@
<button type="button" mat-raised-button [routerLink]="['../general']"> <button type="button" mat-raised-button [routerLink]="['../general']">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0"> <button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
{{ 'labels.buttons.Submit' | translate }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
</mat-card-actions> </mat-card-actions>
@@ -10,12 +10,6 @@
max-width: 37rem; max-width: 37rem;
} }
.lien-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
.expandcollapsebutton { .expandcollapsebutton {
margin-top: -7px; margin-top: -7px;
margin-left: 2%; margin-left: 2%;
@@ -64,9 +64,6 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
maxDate = new Date(); maxDate = new Date();
/** Close Recurring Deposit Account form. */ /** Close Recurring Deposit Account form. */
closeRecurringDepositForm: UntypedFormGroup; closeRecurringDepositForm: UntypedFormGroup;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/** /**
* Retrieves action details template data from `resolve` * Retrieves action details template data from `resolve`
@@ -82,11 +79,6 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount; this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions; this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions; this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
// A matured deposit closes on or after its maturity date - the server refuses anything earlier.
const maturityDate = data.recurringDepositsAccountActionData.maturityDate;
if (Array.isArray(maturityDate) && maturityDate.length >= 3) {
this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]);
}
if (data.recurringDepositsAccountActionData.maturityAmount) { if (data.recurringDepositsAccountActionData.maturityAmount) {
this.title = 'Recurring Deposit Closure on Maturity'; this.title = 'Recurring Deposit Closure on Maturity';
} else { } else {
@@ -100,10 +92,6 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maxDate = this.settingsService.businessDate; this.maxDate = this.settingsService.businessDate;
this.createcloseRecurringDepositForm(); this.createcloseRecurringDepositForm();
this.addTransferDetails(); this.addTransferDetails();
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
} }
/** /**
@@ -111,10 +99,8 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
*/ */
createcloseRecurringDepositForm() { createcloseRecurringDepositForm() {
this.closeRecurringDepositForm = this.formBuilder.group({ this.closeRecurringDepositForm = this.formBuilder.group({
// The business date, not the computer's clock: the branch's working day can differ from the calendar day,
// and closing on the clock's date (before maturity, or before the deposit's last installment) fails.
closedOnDate: [ closedOnDate: [
this.settingsService.businessDate, new Date(),
Validators.required Validators.required
], ],
maturityAmount: [{ value: this.maturityAmount, disabled: true }], maturityAmount: [{ value: this.maturityAmount, disabled: true }],
@@ -11,51 +11,6 @@
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()"> <form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content> <mat-card-content>
<div class="layout-column"> <div class="layout-column">
@if (account) {
<div class="pc-context">
<div class="pc-row">
<span>Account</span><b>{{ account.accountNo }}</b>
</div>
<div class="pc-row">
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
</div>
@if (account.maturityDate) {
<div class="pc-row">
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
</div>
}
@if (account.maturityAmount != null) {
<div class="pc-row">
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
</div>
}
<div class="pc-row">
<span>Pre-closure penalty</span>
<b>
@if (account.preClosurePenalApplicable) {
{{ account.preClosurePenalInterest }}% less interest
@if (account.preClosurePenalInterestOnType?.value) {
({{ account.preClosurePenalInterestOnType.value }})
}
} @else {
None
}
</b>
</div>
</div>
}
@if (totalLienAmount > 0) {
<p class="pc-warn">
This deposit is pledged as loan security (lien {{ currencySymbol }}
{{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()"> <mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input <input
@@ -68,7 +23,6 @@
/> />
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker> <mat-datepicker #closedOnDatePicker></mat-datepicker>
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) { @if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error> <mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
@@ -76,89 +30,6 @@
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
@if (reasonOptions.length > 1) {
<mat-form-field subscriptSizing="dynamic">
<mat-label>Closure reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of reasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
<mat-hint>{{ selectedReasonHint }}</mat-hint>
</mat-form-field>
}
<div class="pc-calc">
<button
type="button"
mat-stroked-button
color="primary"
(click)="calculate()"
[disabled]="
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
"
>
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
</button>
</div>
@if (preview) {
<div class="pc-result">
<div class="pc-row pc-row--total">
<span>Payable on premature closure</span>
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
</div>
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
<div class="pc-row pc-row--muted">
<span>Less than at maturity by</span>
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
</div>
}
<p class="pc-note">
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
</p>
</div>
@if (destinationAccounts.length === 0) {
<p class="pc-warn">
This client has no active savings account to receive the proceeds. Open one first, then close this
deposit.
</p>
} @else {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (savings of destinationAccounts; track savings.id) {
<mat-option [value]="savings.id">
{{ savings.accountNo }} - {{ savings.productName }}
@if (savings.owner) {
(nominee: {{ savings.owner }})
} @else if (savings.id === account?.linkedAccountId) {
(linked)
}
</mat-option>
}
</mat-select>
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
</mat-form-field>
}
}
</div> </div>
</mat-card-content> </mat-card-content>
@@ -166,8 +37,8 @@
<button type="button" mat-raised-button [routerLink]="['../../']"> <button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" [disabled]="!canConfirm"> <button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }} {{ 'labels.buttons.Confirm' | translate }}
</button> </button>
</mat-card-actions> </mat-card-actions>
</form> </form>
@@ -9,45 +9,3 @@
.container { .container {
max-width: 37rem; max-width: 37rem;
} }
.pc-context,
.pc-result {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
border: 1px solid rgb(128 128 128 / 30%);
border-radius: 6px;
}
.pc-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
&--total {
font-size: 15px;
}
&--muted {
opacity: 0.75;
}
}
.pc-calc {
margin: 4px 0 16px;
}
.pc-note {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.75;
}
.pc-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
@@ -8,41 +8,25 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
/** Custom Services */ /** Custom Services */
import { RecurringDepositsService } from '../../recurring-deposits.service'; import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** DepositAccountOnClosureType.TRANSFER_TO_SAVINGS - the only way a broken RD may be paid out. */
const TRANSFER_TO_SAVINGS = 200;
/** PrematureClosureReasonType values the RD closure understands. */
const REASON_NORMAL = 100;
const REASON_DECEASED = 400;
const REASON_NOMINEE_HEIR = 500;
/** /**
* Premature Close Recurring Deposits Account Component * Premature Close Recurring Deposits Account Component
*
* The server will only break a recurring deposit early by transferring the proceeds to one of the client's savings
* accounts (no cash payout, no reinvest), and it needs to be told so: onAccountClosureId and toSavingsAccountId are
* mandatory. This screen used to send only the closure date, so every premature closure was rejected. It now asks the
* server what the deposit would pay on the chosen date (calculatePrematureAmount, which applies the product's
* pre-closure penalty) and only lets the closure be confirmed against that exact figure.
*/ */
@Component({ @Component({
selector: 'mifosx-premature-close-recurring-deposit-account', selector: 'mifosx-premature-close-recurring-deposit-account',
templateUrl: './premature-close-recurring-deposit-account.component.html', templateUrl: './premature-close-recurring-deposit-account.component.html',
styleUrls: ['./premature-close-recurring-deposit-account.component.scss'], styleUrls: ['./premature-close-recurring-deposit-account.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS
CdkTextareaAutosize
] ]
}) })
export class PrematureCloseRecurringDepositAccountComponent implements OnInit { export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
@@ -52,263 +36,72 @@ export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
/** Savings accounts of this deposit's nominees - offered as destinations only for a deceased holder. */ /** Minimum date allowed. */
nomineeSavingsAccounts: any[] = [];
/** Minimum date allowed: the deposit's activation date once loaded. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
/** Maximum date allowed: the business date, and never the maturity date or later (that is a normal Close). */ /** Maximum date allowed. */
maxDate = new Date(); maxDate = new Date();
/** premature close Recurring Deposits Account form. */ /** premature close Recurring Deposits Account form. */
prematureCloseRecurringDepositsAccountForm: UntypedFormGroup; prematureCloseRecurringDepositsAccountForm: UntypedFormGroup;
/** Recurring Deposits Account Id */ /** Recurring Deposits Account Id */
accountId: any; accountId: any;
/** The deposit being closed, for the context panel and the default destination account. */
account: any;
/** Result of the last calculatePrematureAmount call, and the date it was calculated for. */
preview: { payoutAmount: number; savingsAccounts: any[]; closureOptionId: number } | null = null;
previewedFor: string | null = null;
calculating = false;
submitting = false;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {RecurringDepositsService} recurringDepositsService Recurring Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() { constructor() {
this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId']; this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId'];
} }
/**
* Creates the premature close recurring deposits form.
*/
ngOnInit() { ngOnInit() {
this.maxDate = this.settingsService.businessDate; this.maxDate = this.settingsService.businessDate;
this.createprematureCloseRecurringDepositsAccountForm(); this.createprematureCloseRecurringDepositsAccountForm();
this.recurringDepositsService.getRecurringDepositsAccountData(this.accountId).subscribe((account: any) => {
this.account = account;
const activatedOn = this.toDate(account?.timeline?.activatedOnDate);
if (activatedOn) {
this.minDate = activatedOn;
}
const maturity = this.toDate(account?.maturityDate);
if (maturity) {
const dayBeforeMaturity = new Date(maturity);
dayBeforeMaturity.setDate(dayBeforeMaturity.getDate() - 1);
if (dayBeforeMaturity < this.maxDate) {
this.maxDate = dayBeforeMaturity;
}
}
});
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
// A different date or reason is a different payout - the old preview no longer describes what would happen.
this.prematureCloseRecurringDepositsAccountForm.get('closedOnDate').valueChanges.subscribe(() => {
this.preview = null;
this.previewedFor = null;
});
this.prematureCloseRecurringDepositsAccountForm.get('prematureClosureReasonId').valueChanges.subscribe((reason: number) => {
this.preview = null;
this.previewedFor = null;
if (reason === REASON_DECEASED) {
this.loadNomineeSavingsAccounts();
}
});
}
/** Reasons this deposit's product allows; Normal is always there. */
get reasonOptions(): Array<{ id: number; label: string; hint: string }> {
const options = [{ id: REASON_NORMAL, label: 'Normal', hint: "Pre-closure penalty as per the deposit's terms." }];
if (this.account?.noPenaltyNomineeHeirClosure) {
options.push({ id: REASON_NOMINEE_HEIR, label: 'Nominee / legal heir claim', hint: 'No pre-closure penalty.' });
}
if (this.account?.deceasedSettlement) {
options.push({
id: REASON_DECEASED,
label: 'Account holder deceased',
hint: "No penalty, allowed inside lock-in; may be paid to a nominee's savings account."
});
}
return options;
}
get selectedReasonHint(): string {
const reason = this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId;
return this.reasonOptions.find((option) => option.id === reason)?.hint ?? '';
}
/** The client's own savings accounts, plus - for a deceased holder - the nominees' savings accounts. */
get destinationAccounts(): any[] {
const own = (this.preview?.savingsAccounts ?? []).map((acc: any) => ({ ...acc, owner: null }));
if (this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId !== REASON_DECEASED) {
return own;
}
const ownIds = new Set(own.map((acc: any) => acc.id));
return [
...own,
...this.nomineeSavingsAccounts.filter((acc: any) => !ownIds.has(acc.id))
];
}
private loadNomineeSavingsAccounts() {
this.nomineeSavingsAccounts = [];
const clientId = this.account?.clientId;
if (!clientId || !this.account?.accountNo) {
return;
}
this.clientsService.getClientNominees(clientId, this.account.accountNo, 'RD').subscribe((nominees: any) => {
const list: any[] = Array.isArray(nominees) ? nominees : (nominees?.pageItems ?? []);
list
.filter((nominee: any) => nominee?.nomineeClientId && nominee.isActive !== false)
.forEach((nominee: any) => {
this.clientsService.getClientAccountData(nominee.nomineeClientId).subscribe((accounts: any) => {
const savings = (accounts?.savingsAccounts ?? []).filter(
(acc: any) => acc?.depositType?.id === 100 && acc?.status?.active
);
this.nomineeSavingsAccounts = [
...this.nomineeSavingsAccounts,
...savings.map((acc: any) => ({
id: acc.id,
accountNo: acc.accountNo,
productName: acc.productName,
owner: nominee.nomineeDisplayName
}))
];
});
});
});
} }
/**
* Creates the premature close recurring deposits account form.
*/
createprematureCloseRecurringDepositsAccountForm() { createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({ this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [ closedOnDate: [
this.settingsService.businessDate, '',
Validators.required Validators.required
], ]
prematureClosureReasonId: [REASON_NORMAL],
toSavingsAccountId: [
null,
Validators.required
],
transferDescription: ['Premature closure of recurring deposit'],
note: ['']
}); });
} }
get balance(): number | null { /**
return this.account?.summary?.accountBalance ?? null; * Submits the form and premature closes the recurring deposit account,
} * if successful redirects to the recurring deposit account.
*/
get currencySymbol(): string {
return this.account?.currency?.displaySymbol || this.account?.currency?.code || '';
}
/** What breaking early costs compared with letting the deposit run to maturity, when both are known. */
get shortfallVsMaturity(): number | null {
const maturityAmount = this.account?.maturityAmount;
if (!this.preview || maturityAmount == null) {
return null;
}
return Number(maturityAmount) - Number(this.preview.payoutAmount);
}
get canConfirm(): boolean {
return (
this.totalLienAmount <= 0 &&
!!this.preview &&
this.previewedFor === this.formattedClosedOnDate &&
this.prematureCloseRecurringDepositsAccountForm.valid &&
!this.submitting
);
}
private get formattedClosedOnDate(): string | null {
const value = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (!value) {
return null;
}
return value instanceof Date ? this.dateUtils.formatDate(value, this.settingsService.dateFormat) : value;
}
/** Asks the server what the deposit pays if broken on the chosen date. */
calculate() {
const closedOnDate = this.formattedClosedOnDate;
if (!closedOnDate) {
return;
}
this.calculating = true;
const data = {
closedOnDate,
prematureClosureReasonId: this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data).subscribe({
next: (response: any) => {
const savingsAccounts = response?.savingsAccounts ?? [];
const closureOptionId =
(response?.onAccountClosureOptions ?? []).find((option: any) => option.id === TRANSFER_TO_SAVINGS)?.id ??
TRANSFER_TO_SAVINGS;
this.preview = { payoutAmount: response?.maturityAmount, savingsAccounts, closureOptionId };
this.previewedFor = closedOnDate;
this.calculating = false;
this.selectDefaultDestination(savingsAccounts);
},
error: () => {
this.calculating = false;
}
});
}
/** The deposit's own linked savings account, when it has one the client can still receive into. */
private selectDefaultDestination(savingsAccounts: any[]) {
const control = this.prematureCloseRecurringDepositsAccountForm.get('toSavingsAccountId');
if (control.value && savingsAccounts.some((acc: any) => acc.id === control.value)) {
return;
}
const linkedId = this.account?.linkedAccountId;
const linked = savingsAccounts.find((acc: any) => acc.id === linkedId);
control.setValue(linked?.id ?? (savingsAccounts.length === 1 ? savingsAccounts[0].id : null));
}
submit() { submit() {
if (!this.canConfirm) { const prematureCloseRecurringDepositsAccountFormData = this.prematureCloseRecurringDepositsAccountForm.value;
return; const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedOnDate: Date = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (prematureCloseRecurringDepositsAccountFormData.closedOnDate instanceof Date) {
prematureCloseRecurringDepositsAccountFormData.closedOnDate = this.dateUtils.formatDate(
prevClosedOnDate,
dateFormat
);
} }
const formValue = this.prematureCloseRecurringDepositsAccountForm.value; const data = {
const data: any = { ...prematureCloseRecurringDepositsAccountFormData,
closedOnDate: this.formattedClosedOnDate, dateFormat,
onAccountClosureId: this.preview.closureOptionId, locale
prematureClosureReasonId: formValue.prematureClosureReasonId,
toSavingsAccountId: formValue.toSavingsAccountId,
transferDescription: formValue.transferDescription,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
}; };
if (formValue.note) { this.recurringDepositsService
data.note = formValue.note; .executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data)
} .subscribe(() => {
this.submitting = true;
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route }); this.router.navigate(['../../'], { relativeTo: this.route });
}, });
error: () => {
this.submitting = false;
}
});
}
/** API dates arrive as [yyyy, m, d] arrays or ISO strings. */
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return new Date(value[0], value[1] - 1, value[2]);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
} }
} }
@@ -30,14 +30,6 @@
@if (actions['Close']) { @if (actions['Close']) {
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account> <mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
} }
<!-- A direct deposit from the account screen skips the manager's approval; RD installments come in through the @if (actions['Deposit'] || actions['Withdrawal']) {
cashier's Receipt or Auto Debit instead, so the Deposit action is not offered here even by URL. -->
@if (actions['Deposit']) {
<p>
RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked
savings account.
</p>
}
@if (actions['Withdrawal']) {
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account> <mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
} }
@@ -30,50 +30,11 @@
<div class="racc-alert racc-alert--editable"> <div class="racc-alert racc-alert--editable">
<div class="racc-alert__content"> <div class="racc-alert__content">
<h4>Auto Debit Facility</h4> <h4>Auto Debit Facility</h4>
@if (recurringDepositsAccountProductTemplate?.autoDebitFacility) { <p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
<p>
Collect each installment automatically on its due date from the linked Savings account (Deposit Account
Link, Details tab). A short balance leaves it overdue and it is retried on the next run.
</p>
} @else {
<p>This product does not offer Auto Debit.</p>
}
</div> </div>
<mat-slide-toggle <mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
[(ngModel)]="autoDebitFacility"
[disabled]="!recurringDepositsAccountProductTemplate?.autoDebitFacility"
color="primary"
></mat-slide-toggle>
</div> </div>
@if (lienAllowed) {
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Loan against this RD (Lien)</h4>
<p>
How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken.
Product limit: {{ productLienLimit }}%.
</p>
@if (lienPercentageError) {
<p class="racc-lien-error">{{ lienPercentageError }}</p>
}
</div>
<mat-form-field appearance="outline" class="racc-lien-field" subscriptSizing="dynamic">
<mat-label>Max loan (%)</mat-label>
<input
matInput
type="number"
min="1"
[max]="productLienLimit"
step="0.01"
[(ngModel)]="lienPercentage"
[ngModelOptions]="{ standalone: true }"
/>
<span matTextSuffix>%</span>
</mat-form-field>
</div>
}
<div class="racc-alert"> <div class="racc-alert">
<div class="racc-alert__content"> <div class="racc-alert__content">
<h4>Dormancy Alert</h4> <h4>Dormancy Alert</h4>
@@ -126,15 +126,6 @@ $gray-900: #202124;
} }
} }
.racc-lien-field {
width: 140px;
flex-shrink: 0;
}
.racc-lien-error {
color: #c62828;
}
.racc-alert--editable { .racc-alert--editable {
border-color: #bae6fd; border-color: #bae6fd;
background: #f0f9ff; background: #f0f9ff;
@@ -6,9 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, Input, OnChanges, SimpleChanges, ViewChild, inject } from '@angular/core'; import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component'; import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component'; import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { MatSlideToggle } from '@angular/material/slide-toggle'; import { MatSlideToggle } from '@angular/material/slide-toggle';
@@ -37,14 +36,7 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
showNomineeForm = false; showNomineeForm = false;
nomineeError = ''; nomineeError = '';
private recurringDepositsService = inject(RecurringDepositsService);
autoDebitFacility = false; autoDebitFacility = false;
/**
* Lien Marked products only: how much of this RD may be given as loan, as % of its balance when the loan is taken.
* Starts at the product's percentage; may be lowered, never raised above it.
*/
lienPercentage: number | null = null;
private existingAccountApplied = false; private existingAccountApplied = false;
@@ -58,44 +50,13 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) { if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees]; this.nominees = [...ea.nominees];
} }
// The account response does not carry the deposit's own lien percentage; the lien endpoint does.
if (ea.id) {
this.recurringDepositsService.getRecurringDepositLiens(ea.id).subscribe((response: any) => {
this.lienPercentage = response?.lienPercentage ?? response?.productLienPercentage ?? null;
});
}
} }
if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) { if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
const pt = changes['recurringDepositsAccountProductTemplate'].currentValue; const pt = changes['recurringDepositsAccountProductTemplate'].currentValue;
this.autoDebitFacility = !!pt.autoDebitFacility; this.autoDebitFacility = !!pt.autoDebitFacility;
this.lienPercentage = pt.lienMarked ? (pt.lienPercentage ?? 100) : null;
} }
} }
get lienAllowed(): boolean {
return !!this.recurringDepositsAccountProductTemplate?.lienMarked;
}
/** The product's ceiling; a product with no percentage allows the whole balance. */
get productLienLimit(): number {
return Number(this.recurringDepositsAccountProductTemplate?.lienPercentage) || 100;
}
/** Why the entered percentage would be refused, or null when it is fine. */
get lienPercentageError(): string | null {
if (!this.lienAllowed) {
return null;
}
if (this.lienPercentage === null || this.lienPercentage === undefined || `${this.lienPercentage}` === '') {
return 'Enter how much of the RD balance can be given as loan.';
}
const value = Number(this.lienPercentage);
if (!(value > 0) || value > this.productLienLimit) {
return `Must be more than 0% and at most ${this.productLienLimit}% (product limit).`;
}
return null;
}
get totalShare(): number { get totalShare(): number {
return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0); return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0);
} }
@@ -151,8 +112,7 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
get currencyStepData() { get currencyStepData() {
return { return {
autoDebitFacility: this.autoDebitFacility, autoDebitFacility: this.autoDebitFacility
...(this.lienAllowed && this.lienPercentage != null ? { lienPercentage: Number(this.lienPercentage) } : {})
}; };
} }
} }
@@ -121,30 +121,11 @@
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }} {{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span> </span>
</div> </div>
<div class="rad-field rad-info-row">
<span class="rad-info-label">Introducer Required</span>
<span class="rad-badge" [class.rad-badge--yes]="productTemplate.introducerRequired">
{{ productTemplate.introducerRequired ? 'Yes' : 'No' }}
</span>
</div>
</div> </div>
</div> </div>
<!-- The product requires an introducer: an existing customer, never the depositor themselves. -->
@if (productTemplate?.introducerRequired) {
<mifosx-introducer-search
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
[preselectedIntroducer]="
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
"
(introducerChange)="onIntroducerChange($event)"
></mifosx-introducer-search>
}
<!-- ══ Deposit Account Link ══════════════════════════════════════════ --> <!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
<!-- Also offered when the product allows Auto Debit: that is the account installments are collected from. --> @if (productTemplate?.shareLinkingEnabled) {
@if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
<div class="dal-card"> <div class="dal-card">
<div class="dal-card__header"> <div class="dal-card__header">
<div class="dal-card__header-left"> <div class="dal-card__header-left">
@@ -24,7 +24,6 @@ import { Dates } from 'app/core/utils/dates';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
/** Custom Services */ /** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { RecurringDepositsService } from '../../recurring-deposits.service'; import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
import { import {
@@ -63,7 +62,6 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges { export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private recurringDepositsService = inject(RecurringDepositsService); private recurringDepositsService = inject(RecurringDepositsService);
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
@@ -119,10 +117,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
primaryClientName = ''; primaryClientName = '';
primaryClientCif = ''; primaryClientCif = '';
primaryClientId: number | null = null; primaryClientId: number | null = null;
/** Introducer - asked for only when the product has "Introducer Required" on. */
introducerId: number | null = null;
introducerName: string | null = null;
introducerCif: string | null = null;
primaryHolderShare = 50; primaryHolderShare = 50;
// ── Status of Account (RBI) ──────────────────────────────────────────── // ── Status of Account (RBI) ────────────────────────────────────────────
@@ -459,14 +453,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
} }
} }
this.productTemplate = response; this.productTemplate = response;
// The account template leaves out the product's installment limits; the calculator and the submit check
// read them from this template, so they are taken from the product itself.
if (response.minDepositAmount == null && response.maxDepositAmount == null) {
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
response.minDepositAmount = product?.minDepositAmount ?? null;
response.maxDepositAmount = product?.maxDepositAmount ?? null;
});
}
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType); this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null; this.selectedAccountType = response.accountType ?? null;
@@ -477,7 +463,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
this.recurringDepositsAccountProductTemplate.emit(response); this.recurringDepositsAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions; this.fieldOfficerData = response.fieldOfficerOptions;
this.isProductSelected = true; this.isProductSelected = true;
if (response.shareLinkingEnabled || response.autoDebitFacility) { if (response.shareLinkingEnabled) {
this.loadClientAccounts(); this.loadClientAccounts();
} }
this.recurringDepositAccountDetailsForm.patchValue({ this.recurringDepositAccountDetailsForm.patchValue({
@@ -624,12 +610,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || ''; return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || '';
} }
onIntroducerChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.introducerId = event?.clientId ?? null;
this.introducerName = event?.displayName ?? null;
this.introducerCif = event?.cifNumber ?? null;
}
onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void { onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.guardianClientId = event?.clientId ?? null; this.guardianClientId = event?.clientId ?? null;
this.guardianName = event?.displayName ?? ''; this.guardianName = event?.displayName ?? '';
@@ -815,12 +795,6 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null; value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null;
} }
if (this.productTemplate?.introducerRequired && this.introducerId) {
value.introducerId = this.introducerId;
value.introducerName = this.introducerName;
value.introducerCifNumber = this.introducerCif;
}
if (this.selectedLinkedAccount) { if (this.selectedLinkedAccount) {
value.linkedAccountId = this.selectedLinkedAccount.id; value.linkedAccountId = this.selectedLinkedAccount.id;
value.linkedAccountType = this.selectedLinkedAccount.accountType; value.linkedAccountType = this.selectedLinkedAccount.accountType;
@@ -93,13 +93,7 @@
<div class="fdrc-calc-field"> <div class="fdrc-calc-field">
<label class="fdrc-calc-label"> <label class="fdrc-calc-label">
Monthly Installment Monthly Installment
<span class="fdrc-calc-optional" <span class="fdrc-calc-optional">(optional — for maturity estimation)</span>
>(required@if (minInstallment > 0) {
— min ₹{{ minInstallment }}
}@if (maxInstallment > 0) {
, max ₹{{ maxInstallment }}
})</span
>
</label> </label>
<div class="fdrc-amount-row"> <div class="fdrc-amount-row">
<span class="fdrc-amount-prefix">₹</span> <span class="fdrc-amount-prefix">₹</span>
@@ -263,21 +257,6 @@
</span> </span>
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span> <span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div> </div>
@if (matchResult.interestCalculation) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Type</span>
<span class="fdrc-maturity-row__value">
{{ matchResult.interestCalculation }}
@if (matchResult.interestCalculation === 'Compounding') {
({{ interestPeriodLabel(matchResult.compoundingPeriod) }})
}
</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Credited</span>
<span class="fdrc-maturity-row__value">{{ interestPeriodLabel(matchResult.postingPeriod) }}</span>
</div>
}
<div class="fdrc-maturity-row fdrc-maturity-row--interest"> <div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">Estimated Interest Earned</span> <span class="fdrc-maturity-row__label">Estimated Interest Earned</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span> <span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
@@ -288,23 +267,6 @@
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span >₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
> >
</div> </div>
@if (matchResult.postingSchedule?.length) {
<details class="fdrc-credits">
<summary>Interest credits to the RD ({{ matchResult.postingSchedule.length }})</summary>
<table class="fdrc-credits__table">
<tr>
<th>Credited on</th>
<th class="text-right">Interest</th>
</tr>
@for (p of matchResult.postingSchedule; track p.date) {
<tr>
<td>{{ p.date }}</td>
<td class="text-right">₹ {{ p.interest | number: '1.2-2' }}</td>
</tr>
}
</table>
</details>
}
</div> </div>
</div> </div>
} }
@@ -1127,31 +1127,3 @@ $red-light: #fee2e2;
font-size: 14px; font-size: 14px;
} }
} }
.fdrc-credits {
margin-top: 8px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 600;
color: #2563eb;
}
&__table {
width: 100%;
margin-top: 6px;
border-collapse: collapse;
th,
td {
padding: 3px 6px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
.text-right {
text-align: right;
}
}
}
@@ -42,7 +42,6 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe'; import { YesnoPipe } from '../../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { buildRdChart, tenureInMonths } from 'app/products/recurring-deposit-products/rd-product-chart';
export type RdPeriodUnit = 'Days' | 'Months' | 'Years'; export type RdPeriodUnit = 'Days' | 'Months' | 'Years';
@@ -54,12 +53,6 @@ export interface SlabMatchResult {
interestEarned?: number; interestEarned?: number;
tenureDays?: number; tenureDays?: number;
daysInYear?: number; daysInYear?: number;
/** From the product, as the server calculated it: Simple / Compounding, and the period names. */
interestCalculation?: string;
compoundingPeriod?: string;
postingPeriod?: string;
/** Each interest credit the deposit will receive, if every installment is paid on time. */
postingSchedule?: { date: string; interest: number }[];
} }
/** /**
@@ -130,62 +123,20 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive); return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
} }
/**
* The product's own chart - the one the server prices the RD from - with each slab's senior-citizen rate taken
* from the CBS base chart it was built from. Falls back to the base chart only when the product has no chart.
*/
get interestRateChartData(): any[] { get interestRateChartData(): any[] {
const cbsSlabs: any[] = this.activeBase?.slabs || []; if (this.activeBase?.slabs?.length) {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return this.normalizeCbsSlabs(this.activeBase.slabs);
const productSlabs: any[] =
template?.accountChart?.chartSlabs ??
template?.activeChart?.chartSlabs ??
template?.interestRateCharts?.[0]?.chartSlabs ??
template?.interestRateChart?.chartSlabs ??
template?.chartSlabs ??
[];
if (this.chartCache?.cbsSlabs !== cbsSlabs || this.chartCache?.productSlabs !== productSlabs) {
this.chartCache = { cbsSlabs, productSlabs, slabs: this.mergeChart(cbsSlabs, productSlabs) };
} }
return this.chartCache.slabs;
}
private chartCache: { cbsSlabs: any[]; productSlabs: any[]; slabs: any[] } | null = null;
/** The product's minimum deposit term in months - the server refuses anything shorter. Null when it sets none. */
/** The product's installment limits (0 = no limit). */
get minInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
}
get maxInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
}
get productMinTenureMonths(): number | null {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return tenureInMonths(template?.minDepositTerm, template?.minDepositTermType?.id); if (!template) return [];
} return (
template.accountChart?.chartSlabs ??
private mergeChart(cbsSlabs: any[], productSlabs: any[]): any[] { template.activeChart?.chartSlabs ??
const baseSlabs = this.normalizeCbsSlabs(buildRdChart(cbsSlabs, [], null).slabs); template.interestRateCharts?.[0]?.chartSlabs ??
if (!productSlabs.length) { template.interestRateChart?.chartSlabs ??
return baseSlabs; template.chartSlabs ??
} []
// Products made before RD charts moved to months still hold the base chart's own year slabs );
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
return productSlabs.map((slab: any) => {
if (slab.seniorCitizenRate != null) {
return slab;
}
const base = candidates.find(
(candidate: any) =>
candidate.fromPeriod === slab.fromPeriod &&
(candidate.toPeriod ?? null) === (slab.toPeriod ?? null) &&
this.periodLabel(candidate) === this.periodLabel(slab)
);
return base?.seniorCitizenRate != null ? { ...slab, seniorCitizenRate: base.seniorCitizenRate } : slab;
});
} }
private normalizeCbsSlabs(slabs: any[]): any[] { private normalizeCbsSlabs(slabs: any[]): any[] {
@@ -195,10 +146,6 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
})); }));
} }
private periodLabel(slab: any): string {
return String(slab.periodType?.value ?? slab.periodTypeLabel ?? '').toLowerCase();
}
chartSlabsDisplayedColumns: string[] = [ chartSlabsDisplayedColumns: string[] = [
'period', 'period',
'amountRange', 'amountRange',
@@ -306,28 +253,6 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return period; // days return period; // days
} }
/**
* Whether the tenure falls in the slab, counted the way the server counts it: in whole units of the slab's own
* period type, so a 30-month RD is "2 years" against a year slab and lands in "1-2 Years" rather than between
* slabs. Day and week slabs keep the day comparison.
*/
private isTenureInSlab(slab: any, tenureMonths: number, tenureDays: number): boolean {
const label = this.periodLabel(slab) || 'days';
let tenure: number;
let from = slab.fromPeriod ?? 0;
let to = slab.toPeriod ?? Infinity;
if (label.includes('month')) {
tenure = tenureMonths;
} else if (label.includes('year')) {
tenure = Math.floor(tenureMonths / 12);
} else {
tenure = tenureDays;
from = this.slabPeriodToDays(from, label);
to = to === Infinity ? Infinity : this.slabPeriodToDays(to, label);
}
return tenure >= from && tenure <= to;
}
calculateRate() { calculateRate() {
this.validationWarning = null; this.validationWarning = null;
this.matchResult = null; this.matchResult = null;
@@ -338,26 +263,6 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
} }
const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit); const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit);
const enteredMonths = this.tenurePeriodUnit === 'Years' ? this.tenureValue * 12 : this.tenureValue;
const minimumMonths = this.productMinTenureMonths;
if (minimumMonths != null && enteredMonths < minimumMonths) {
this.validationWarning = `Minimum RD tenure for this product is ${minimumMonths} months.`;
return;
}
// The installment must be within the product's limits - the same check the submit and the server make, shown
// here so a wrong amount is caught before the rest of the form is filled.
if (!this.principalAmount || this.principalAmount <= 0) {
this.validationWarning = 'Please enter the monthly installment amount.';
return;
}
if (this.minInstallment > 0 && this.principalAmount < this.minInstallment) {
this.validationWarning = `Minimum monthly installment for this product is ₹${this.minInstallment}.`;
return;
}
if (this.maxInstallment > 0 && this.principalAmount > this.maxInstallment) {
this.validationWarning = `Maximum monthly installment for this product is ₹${this.maxInstallment}.`;
return;
}
const slabs: any[] = this.interestRateChartData || []; const slabs: any[] = this.interestRateChartData || [];
if (!slabs.length) { if (!slabs.length) {
@@ -370,7 +275,11 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
for (let i = 0; i < slabs.length; i++) { for (let i = 0; i < slabs.length; i++) {
const slab = slabs[i]; const slab = slabs[i];
if (this.isTenureInSlab(slab, enteredMonths, enteredDays)) { const periodTypeValue = slab.periodType?.value || 'Days';
const fromDays = this.slabPeriodToDays(slab.fromPeriod ?? 0, periodTypeValue);
const toDays = slab.toPeriod != null ? this.slabPeriodToDays(slab.toPeriod, periodTypeValue) : Infinity;
if (enteredDays >= fromDays && enteredDays <= toDays) {
matchedSlab = slab; matchedSlab = slab;
matchedIndex = i; matchedIndex = i;
break; break;
@@ -420,20 +329,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
tenureInMonths: tenureInMonths, tenureInMonths: tenureInMonths,
interestCompoundingPeriodInMonths: compoundingMonths interestCompoundingPeriodInMonths: compoundingMonths
}; };
// With the product named, the server calculates with the product's own interest settings (simple/compounding,
// compounding and posting periods, days in year) - the same figure the opened account will carry.
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const productId = template?.depositProductId ?? template?.productId;
if (productId) {
calculationData.productId = productId;
}
if (this.submittedOnDate) { if (this.submittedOnDate) {
const date = new Date(this.submittedOnDate); const date = new Date(this.submittedOnDate);
// The local calendar date - toISOString() is UTC, which in IST turns midnight of the 9th into the 8th and calculationData.submittedOnDate = date.toISOString().split('T')[0];
// starts the projection a day early (a different maturity amount from the one the account gets).
const pad = (n: number) => String(n).padStart(2, '0');
calculationData.submittedOnDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API
calculationData.locale = 'en'; calculationData.locale = 'en';
} }
@@ -442,10 +341,6 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
result.maturityAmount = apiResult.maturityAmount; result.maturityAmount = apiResult.maturityAmount;
result.interestEarned = apiResult.totalInterest; result.interestEarned = apiResult.totalInterest;
result.daysInYear = apiResult.daysInYear; result.daysInYear = apiResult.daysInYear;
result.interestCalculation = apiResult.interestCalculation;
result.compoundingPeriod = apiResult.compoundingPeriod;
result.postingPeriod = apiResult.postingPeriod;
result.postingSchedule = apiResult.postingSchedule;
this.matchResult = { ...result }; this.matchResult = { ...result };
this.emitTenureSelection(); this.emitTenureSelection();
}); });
@@ -455,18 +350,6 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
} }
} }
/** "QUATERLY" / "BIANNUAL" / "MONTHLY" (server enum names) as a person would say them. */
interestPeriodLabel(name?: string): string {
const labels: Record<string, string> = {
DAILY: 'Daily',
MONTHLY: 'Monthly',
QUATERLY: 'Quarterly',
BIANNUAL: 'Half-yearly',
ANNUAL: 'Yearly'
};
return name ? (labels[name] ?? name) : '';
}
private emitTenureSelection() { private emitTenureSelection() {
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (frequencyId !== null && this.matchResult) { if (frequencyId !== null && this.matchResult) {
@@ -558,54 +441,76 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
// ── Header Getters ────────────────────────────────────────────────────── // ── Header Getters ──────────────────────────────────────────────────────
/** get chartName(): string {
* The RD product's own chart - the one the deposit is actually priced on. The CBS base chart (an FD master the if (this.activeBase) {
* product's chart was built from) is shown only when the product has no chart of its own. return this.activeBase.versionLabel || 'CBS Base Interest Rate';
*/ }
private get productChart(): any {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return ( return (
template?.accountChart || template?.accountChart?.name ||
template?.activeChart || template?.activeChart?.name ||
template?.interestRateCharts?.[0] || template?.interestRateCharts?.[0]?.name ||
template?.interestRateChart || template?.interestRateChart?.name ||
null template?.name ||
''
); );
} }
get chartName(): string {
if (this.productChart) {
return this.productChart.name || '';
}
return this.activeBase?.versionLabel || 'CBS Base Interest Rate';
}
get chartFromDate(): any { get chartFromDate(): any {
if (this.productChart) { if (this.activeBase) {
return this.productChart.fromDate; return this.activeBase.effectiveFromDate;
} }
return this.activeBase?.effectiveFromDate; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.fromDate ||
template?.activeChart?.fromDate ||
template?.interestRateCharts?.[0]?.fromDate ||
template?.interestRateChart?.fromDate ||
template?.fromDate
);
} }
get chartEndDate(): any { get chartEndDate(): any {
if (this.productChart) { if (this.activeBase) {
return this.productChart.endDate; return null;
} }
return null; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.endDate ||
template?.activeChart?.endDate ||
template?.interestRateCharts?.[0]?.endDate ||
template?.interestRateChart?.endDate ||
template?.endDate
);
} }
get chartDescription(): string { get chartDescription(): string {
if (this.productChart) { if (this.activeBase) {
return this.productChart.description || ''; return this.activeBase.remarks || '';
} }
return this.activeBase?.remarks || ''; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.description ||
template?.activeChart?.description ||
template?.interestRateCharts?.[0]?.description ||
template?.interestRateChart?.description ||
template?.description ||
''
);
} }
get isActive(): boolean { get isActive(): boolean {
if (this.productChart) { if (this.activeBase) {
return this.productChart.isActive ?? true; return this.activeBase.isActive;
} }
return this.activeBase?.isActive ?? true; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.isActive ??
template?.activeChart?.isActive ??
template?.interestRateCharts?.[0]?.isActive ??
template?.interestRateChart?.isActive ??
true
);
} }
get isPrimaryGroupingByAmount(): boolean { get isPrimaryGroupingByAmount(): boolean {
@@ -84,17 +84,25 @@
</div> </div>
} }
<!-- The premature-closure penalty is the product's, shown here and never changed per account. --> <mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
<div class="flex-98 margin-v rdmt-penal"> {{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
<div class="rdmt-penal__title">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} <span class="rdmt-penal__tag">From Product</span></div> </mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<div class="rdmt-penal__row"> @if (closureSettingsForm.value.preClosurePenalApplicable) {
<span>Penal Interest: <strong>{{ closureSettingsForm.value.preClosurePenalInterest }}%</strong></span> <mat-form-field class="flex-48">
<span>Period: <strong>{{ penalPeriodLabel }}</strong></span> <mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
</div> <input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
<div class="rdmt-penal__hint">On premature closure the rate is reduced by this much.</div> </mat-form-field>
} @else {
<div class="rdmt-penal__row"><strong>No</strong> - no penal interest is charged on premature closure of this product.</div> <mat-form-field class="flex-48">
} <mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
</div> <mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</form> </form>
@@ -150,37 +150,3 @@
} }
} }
} }
.rdmt-penal {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
font-size: 13px;
&__title {
font-weight: 600;
margin-bottom: 6px;
}
&__tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 8px;
background: #e0f2fe;
color: #0369a1;
font-size: 10px;
font-weight: 700;
}
&__row {
display: flex;
gap: 24px;
}
&__hint {
margin-top: 4px;
font-size: 11px;
color: #64748b;
}
}
@@ -82,7 +82,7 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
); );
this.savingsAccountsData = template.savingsAccounts ?? []; this.savingsAccountsData = template.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? []; this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
{ if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
this.closureSettingsForm.patchValue({ this.closureSettingsForm.patchValue({
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false, preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
preClosurePenalInterest: template.preClosurePenalInterest ?? '', preClosurePenalInterest: template.preClosurePenalInterest ?? '',
@@ -103,21 +103,9 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
} }
/** "Whole Term" / "Till Premature Withdrawal", as the product has it. */ /** Chosen maturity/closure settings, ready to merge into the submit payload. */
get penalPeriodLabel(): string {
const id = this.closureSettingsForm.value.preClosurePenalInterestOnTypeId;
return this.preClosurePenalInterestOnTypeData.find((option: any) => option.id === id)?.value ?? '-';
}
/**
* Chosen maturity/closure settings, ready to merge into the submit payload. The premature-closure penalty is left
* out: it is the product's, which the submit already carries and the server applies regardless.
*/
get closureSettings() { get closureSettings() {
const settings = this.closureSettingsForm.getRawValue(); const settings = this.closureSettingsForm.getRawValue();
delete settings.preClosurePenalApplicable;
delete settings.preClosurePenalInterest;
delete settings.preClosurePenalInterestOnTypeId;
for (const key in settings) { for (const key in settings) {
if (settings[key] === '') { if (settings[key] === '') {
delete settings[key]; delete settings[key];
@@ -878,12 +878,12 @@
} }
<div class="preview-field"> <div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span> <span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ productPenal.applicable | yesNo }}</span> <span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
</div> </div>
@if (productPenal.applicable) { @if (closureSettings?.preClosurePenalApplicable) {
<div class="preview-field"> <div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span> <span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ productPenal.interest }}%</span> <span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
</div> </div>
<div class="preview-field"> <div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span> <span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
@@ -218,20 +218,10 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—'; return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
} }
/** The premature-closure penalty is the product's (the account cannot change it), so it is shown from the product. */
get productPenal(): { applicable: boolean; interest: any; typeId: any } {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return {
applicable: !!template?.preClosurePenalApplicable,
interest: template?.preClosurePenalInterest,
typeId: template?.preClosurePenalInterestOnType?.id
};
}
get closurePenalInterestOnTypeLabel(): string { get closurePenalInterestOnTypeLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.preClosurePenalInterestOnTypeOptions ?? []; const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.productPenal.typeId); const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
return match?.value ?? '—'; return match?.value ?? '—';
} }
@@ -200,27 +200,6 @@
}} }}
</span> </span>
</div> </div>
@if (lienLimit) {
<div class="nx-row">
<span class="nx-row__label">Loan against RD ({{ lienLimit.percentage }}% of balance)</span>
<span class="nx-row__value nx-row__value--amount">
up to {{ lienLimit.maxLoanAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}, available
{{ lienLimit.availableForLoan | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (totalLienAmount > 0) {
<div class="nx-row">
<span class="nx-row__label">Lien Marked (loan security)</span>
<span class="nx-row__value nx-row__value--amount">
{{ totalLienAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
-
@for (lien of liens; track lien.loanId; let last = $last) {
Loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
</span>
</div>
}
<div class="nx-row"> <div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Recurring Deposit Amount' | translate }}</span> <span class="nx-row__label">{{ 'labels.inputs.Recurring Deposit Amount' | translate }}</span>
<span class="nx-row__value nx-row__value--amount"> <span class="nx-row__value nx-row__value--amount">
@@ -15,7 +15,6 @@ import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
@Component({ @Component({
selector: 'mifosx-general-tab', selector: 'mifosx-general-tab',
@@ -35,22 +34,14 @@ export class GeneralTabComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private recurringDepositsService = inject(RecurringDepositsService);
recurringDepositsAccountData: any; recurringDepositsAccountData: any;
isprematureAllowed = false; isprematureAllowed = false;
entityType: string; entityType: string;
currency: Currency; currency: Currency;
/** Loans this deposit secures; empty when it is free of lien. */
liens: any[] = [];
totalLienAmount = 0;
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
constructor() { constructor() {
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => { this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
this.recurringDepositsAccountData = data.recurringDepositsAccountData; this.recurringDepositsAccountData = data.recurringDepositsAccountData;
this.loadLiens(this.recurringDepositsAccountData?.id);
this.currency = this.recurringDepositsAccountData.currency; this.currency = this.recurringDepositsAccountData.currency;
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null; this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
if (this.router.url.includes('clients')) { if (this.router.url.includes('clients')) {
@@ -62,24 +53,4 @@ export class GeneralTabComponent {
} }
}); });
} }
private loadLiens(accountId: any) {
this.liens = [];
this.totalLienAmount = 0;
this.lienLimit = null;
if (!accountId) {
return;
}
this.recurringDepositsService.getRecurringDepositLiens(accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
this.lienLimit = response?.lienAllowed
? {
percentage: Number(response.effectiveLienPercentage) || 0,
maxLoanAmount: Number(response.maxLoanAmount) || 0,
availableForLoan: Number(response.availableForLoan) || 0
}
: null;
});
}
} }
@@ -82,20 +82,6 @@
<span class="rd-kpi__label">{{ 'labels.inputs.Deposit Period' | translate }}</span> <span class="rd-kpi__label">{{ 'labels.inputs.Deposit Period' | translate }}</span>
<span class="rd-kpi__value">{{ recurringDepositsAccountData.depositPeriod | number }}</span> <span class="rd-kpi__value">{{ recurringDepositsAccountData.depositPeriod | number }}</span>
</div> </div>
<!-- Interest credited to the deposit so far - by its product's posting period (monthly, quarterly...), plus the
remainder on the maturity date. -->
<div class="rd-kpi rd-kpi--interest">
<span class="rd-kpi__label">Interest Posted</span>
<span class="rd-kpi__value">
{{
(recurringDepositsAccountData.summary.totalInterestPosted || 0)
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
@if (recurringDepositsAccountData.interestPostingPeriodType?.value) {
<span class="rd-kpi__sub">Credited {{ recurringDepositsAccountData.interestPostingPeriodType.value }}</span>
}
</div>
</div> </div>
} }
@@ -212,17 +212,6 @@ $border: #e2e8f0;
background: rgb(139 92 246 / 22%); background: rgb(139 92 246 / 22%);
border-color: rgb(139 92 246 / 35%); border-color: rgb(139 92 246 / 35%);
} }
&--interest {
background: rgb(16 185 129 / 22%);
border-color: rgb(16 185 129 / 35%);
}
&__sub {
margin-top: 1px;
font-size: 9.5px;
color: rgb(255 255 255 / 75%);
}
} }
/* Status pill */ /* Status pill */
@@ -40,10 +40,6 @@ import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe'; import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component'; import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { ProductsService } from 'app/products/products.service';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { AlertService } from 'app/core/alert/alert.service';
/** /**
* RecurringDeposits Account View Component * RecurringDeposits Account View Component
@@ -83,8 +79,6 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService); private authenticationService = inject(AuthenticationService);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
dialog = inject(MatDialog); dialog = inject(MatDialog);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
@@ -111,7 +105,6 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'interest', 'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest', 'Disable Withhold Tax': 'interest',
'Premature Close': 'danger', 'Premature Close': 'danger',
'Extend Tenure': 'admin',
'Undo Activation': 'admin', 'Undo Activation': 'admin',
Close: 'danger', Close: 'danger',
'Modify Application': 'admin', 'Modify Application': 'admin',
@@ -135,7 +128,6 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'percentage', 'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage', 'Disable Withhold Tax': 'percentage',
'Premature Close': 'times-circle', 'Premature Close': 'times-circle',
'Extend Tenure': 'calendar-alt',
'Undo Activation': 'undo', 'Undo Activation': 'undo',
Close: 'times-circle', Close: 'times-circle',
'Modify Application': 'edit', 'Modify Application': 'edit',
@@ -192,30 +184,6 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.setConditionalButtons(); this.setConditionalButtons();
this.applyProductRules();
}
/**
* Buttons the deposit's product decides: no Premature Close unless the product has "Premature Withdrawal option" on,
* and an Extend Tenure action when it has "Tenure Flexibility" on. The server enforces both as well.
*/
private applyProductRules() {
const productId = this.recurringDepositsAccountData.depositProductId;
if (!productId) {
return;
}
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
if (!product?.prematureWithdrawalOption) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter((btn) => btn.name !== 'Premature Close');
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter((opt) => opt.name !== 'Premature Close');
}
if (product?.tenureFlexibility && this.recurringDepositsAccountData.status?.value === 'Active') {
this.buttonConfig.addOption({
name: 'Extend Tenure',
taskPermissionName: 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT'
});
}
});
} }
/** /**
@@ -344,61 +312,9 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
case 'Terminate': case 'Terminate':
this.terminateRecurringDepositsAccount(); this.terminateRecurringDepositsAccount();
break; break;
case 'Extend Tenure':
this.extendTenure();
break;
} }
} }
/**
* Lengthens the deposit's tenure (product has Tenure Flexibility on). New installments are added after the current
* last one, so Auto Debit keeps collecting through the added months; the rate becomes the chart's rate for the new
* tenure and the maturity date and amount are worked out again.
*/
private extendTenure() {
const current = Number(this.recurringDepositsAccountData.depositPeriod) || 0;
const unit = this.recurringDepositsAccountData.depositPeriodFrequency?.value || 'Months';
const data = {
title: 'Extend Tenure',
layout: { addButtonText: 'Extend' },
formfields: [
new InputBase({
controlName: 'depositPeriod',
label: `New tenure in ${unit} (now ${current})`,
value: current + 1,
type: 'number',
required: true,
min: current + 1,
order: 1
})
]
};
this.dialog
.open(FormDialogComponent, { data })
.afterClosed()
.subscribe((response: any) => {
const newPeriod = Number(response?.data?.value?.depositPeriod);
if (!response?.data) {
return;
}
if (!newPeriod || newPeriod <= current) {
this.alertService.alert({ type: 'Extend Tenure', message: `The new tenure must be more than ${current} ${unit}.` });
return;
}
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.recurringDepositsAccountData.id, 'extendTenure', {
depositPeriod: newPeriod,
locale: this.settingsService.language.code
})
.subscribe((result: any) => {
if (result?.commandId && !result?.changes) {
this.alertService.alert({ type: 'Extend Tenure', message: 'Tenure extension submitted for approval.' });
}
this.reload();
});
});
}
/** /**
* Deletes Recurring Deposits Account. * Deletes Recurring Deposits Account.
*/ */
@@ -36,9 +36,12 @@ export class RecurringDepositsButtonsConfiguration {
setButtons(status: string) { setButtons(status: string) {
switch (status) { switch (status) {
case 'Active': case 'Active':
// No Deposit button: a direct deposit here skips the manager's approval. RD installments come in
// through the cashier's Receipt (maker-checker) or Auto Debit.
this.buttonsArray = [ this.buttonsArray = [
{
name: 'Deposit',
icon: 'fa fa-arrow-up',
taskPermissionName: 'DEPOSIT_RECURRINGDEPOSITACCOUNT'
},
{ {
name: 'Withdrawal', name: 'Withdrawal',
icon: 'fa fa-arrow-down', icon: 'fa fa-arrow-down',
@@ -100,9 +103,12 @@ export class RecurringDepositsButtonsConfiguration {
name: 'Calculate Interest', name: 'Calculate Interest',
icon: 'fa fa-calculator', icon: 'fa fa-calculator',
taskPermissionName: 'CALCULATEINTEREST_RECURRINGDEPOSITACCOUNT' taskPermissionName: 'CALCULATEINTEREST_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Post Interest',
icon: 'fa fa-money',
taskPermissionName: 'POSTINTEREST_RECURRINGDEPOSITACCOUNT'
} }
// No Post Interest: the term's interest is credited by the server on the maturity date, and again only at
// closure for any days after it.
]; ];
break; break;
case 'Rejected': case 'Rejected':
@@ -31,14 +31,6 @@ export class RecurringDepositsService {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}`, { params: httpParams }); return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}`, { params: httpParams });
} }
/**
* Loans this recurring deposit currently secures (lien), with the amount each one holds.
* Returns { totalLienAmount, liens: [{ loanId, loanAccountNo, loanStatus, amount }] }.
*/
getRecurringDepositLiens(recurringDepositAccountId: any): Observable<any> {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
}
/** /**
* @param clientId Client Id * @param clientId Client Id
* @param clientName Client Name * @param clientName Client Name
@@ -74,9 +74,7 @@
(3%)" while Admin holds 4% states a rate the bank is not on. --> (3%)" while Admin holds 4% states a rate the bank is not on. -->
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div> <div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div> <div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub"> <div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
</div>
</div> </div>
</div> </div>
@@ -143,21 +141,22 @@
</span> </span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="3"><strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong></td> <td colspan="3">
<td class="right foot-demand">{{ totalOf(demandLiabilities, "openingValue") }}</td> <strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong>
<td class="right foot-demand">{{ totalOf(demandLiabilities, "currentValue") }}</td> </td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, 'openingValue') }}</td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, 'currentValue') }}</td>
<td class="right">{{ totalChange(demandLiabilities) }}</td> <td class="right">{{ totalChange(demandLiabilities) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -206,21 +205,22 @@
</span> </span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="3"><strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong></td> <td colspan="3">
<td class="right foot-time">{{ totalOf(timeLiabilities, "openingValue") }}</td> <strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong>
<td class="right foot-time">{{ totalOf(timeLiabilities, "currentValue") }}</td> </td>
<td class="right foot-time">{{ totalOf(timeLiabilities, 'openingValue') }}</td>
<td class="right foot-time">{{ totalOf(timeLiabilities, 'currentValue') }}</td>
<td class="right">{{ totalChange(timeLiabilities) }}</td> <td class="right">{{ totalChange(timeLiabilities) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -263,12 +263,11 @@
<span class="status-chip chip-active">{{ item.status }}</span> <span class="status-chip chip-active">{{ item.status }}</span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
@@ -278,8 +277,8 @@
<td colspan="3"> <td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span> <strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
</td> </td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "openingValue") }}</td> <td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, "currentValue") }}</td> <td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(bankingAssets) }}</td> <td class="right">{{ totalChange(bankingAssets) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -328,12 +327,11 @@
<span class="status-chip chip-active">{{ item.status }}</span> <span class="status-chip chip-active">{{ item.status }}</span>
</td> </td>
</tr> </tr>
} } @empty {
@empty {
<tr class="empty-row"> <tr class="empty-row">
<td colspan="7"> <td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
Admin &rarr; RBI Reserve Configuration. RBI Reserve Configuration.
</td> </td>
</tr> </tr>
} }
@@ -341,8 +339,8 @@
<tfoot> <tfoot>
<tr> <tr>
<td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td> <td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "openingValue") }}</td> <td class="right foot-slr">{{ totalOf(liquidAssets, 'openingValue') }}</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, "currentValue") }}</td> <td class="right foot-slr">{{ totalOf(liquidAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(liquidAssets) }}</td> <td class="right">{{ totalChange(liquidAssets) }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -128,8 +128,7 @@ export class CrrSlrDialogComponent implements OnInit {
if (!columns.length) { if (!columns.length) {
this.unavailable = this.unavailable =
data?.unmetRequirements?.[0] ?? data?.unmetRequirements?.[0] ?? 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
return; return;
} }
// The return computes but says something would make it wrong to file — a rate never set, a // The return computes but says something would make it wrong to file — a rate never set, a
@@ -175,7 +174,6 @@ export class CrrSlrDialogComponent implements OnInit {
}); });
} }
/** /**
* The four tabs, filled from the return's own lines. * The four tabs, filled from the return's own lines.
* *
@@ -33,11 +33,15 @@
rate Admin was on. --> rate Admin was on. -->
<div class="card-metrics"> <div class="card-metrics">
<div class="metric-row"> <div class="metric-row">
<span class="metric-label">CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span> <span class="metric-label"
>CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.crrRequired }}</span> <span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div> </div>
<div class="metric-row"> <div class="metric-row">
<span class="metric-label">SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span> <span class="metric-label"
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.slrRequired }}</span> <span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div> </div>
</div> </div>
@@ -116,7 +116,12 @@ export class DashboardComponent implements OnInit {
const crrNeed = data?.partB?.required?.[latest]; const crrNeed = data?.partB?.required?.[latest];
const slrHeld = data?.partC?.maintained?.[latest]; const slrHeld = data?.partC?.maintained?.[latest];
const slrNeed = data?.partC?.required?.[latest]; const slrNeed = data?.partC?.required?.[latest];
if ([crrHeld, crrNeed, slrHeld, slrNeed].every((v) => v !== null && v !== undefined)) { if ([
crrHeld,
crrNeed,
slrHeld,
slrNeed
].every((v) => v !== null && v !== undefined)) {
this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short'; this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short';
} }
}, },
@@ -0,0 +1,43 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (product) {
<div class="lptc">
<div class="lptc__head">
<strong>{{ product.name }}</strong>
<span class="lptc__chip">{{ category }}</span>
<span class="lptc__chip lptc__chip--type">{{ type }}</span>
</div>
<div class="lptc__grid">
<div>
<span class="lptc__label">Amount</span>
<span>
@if (product.minPrincipal != null || product.maxPrincipal != null) {
{{ product.minPrincipal | number: '1.0-0' }} – {{ product.maxPrincipal | number: '1.0-0' }}
} @else {
—
}
</span>
</div>
<div>
<span class="lptc__label">Tenure (repayments)</span>
<span>
@if (product.minNumberOfRepayments != null || product.maxNumberOfRepayments != null) {
{{ product.minNumberOfRepayments }} – {{ product.maxNumberOfRepayments }}
} @else {
—
}
</span>
</div>
<div>
<span class="lptc__label">Short name</span>
<span>{{ product.shortName || '—' }}</span>
</div>
</div>
</div>
}
@@ -0,0 +1,52 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.lptc {
margin: 4px 0 16px;
padding: 12px 16px;
border: 1px solid #bae6fd;
border-radius: 8px;
background: #f0f9ff;
&__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__chip {
padding: 1px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 600;
&--type {
background: #ede9fe;
color: #5b21b6;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 16px;
font-size: 13px;
color: #1e293b;
}
&__label {
display: block;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
}
@@ -0,0 +1,37 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { productCategoryLabel, productTypeLabel } from 'app/products/loan-products/loan-product-types';
/**
* The product picked on the loan account form, summarised with its category, type and the main limits, so the
* officer can see what the account will be opened under before going on.
*/
@Component({
selector: 'mifosx-loan-product-template-card',
templateUrl: './loan-product-template-card.component.html',
styleUrls: ['./loan-product-template-card.component.scss'],
imports: [DecimalPipe]
})
export class LoanProductTemplateCardComponent {
/** A product from the catalogue (the list shape), or undefined when none is picked. */
@Input() product: any;
get category(): string {
return productCategoryLabel(this.product?.advanceExtensions?.productTypeGroup);
}
get type(): string {
return productTypeLabel(
this.product?.advanceExtensions?.productTypeGroup,
this.product?.advanceExtensions?.productTypeSubGroup
);
}
}
@@ -32,6 +32,9 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
/** /**
* Loans Account Details Step * Loans Account Details Step
@@ -49,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatStepperPrevious, MatStepperPrevious,
FaIconComponent, FaIconComponent,
MatStepperNext, MatStepperNext,
AsyncPipe AsyncPipe,
LoanProductTemplateCardComponent
] ]
}) })
export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
@@ -59,6 +63,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private commons = inject(Commons); private commons = inject(Commons);
private catalog = inject(LoanProductCatalogService);
//** Defining PlaceHolders for the search bar */ //** Defining PlaceHolders for the search bar */
placeHolderLabel = ''; placeHolderLabel = '';
@@ -88,6 +93,27 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
loanId: any = null; loanId: any = null;
/** Category and product type narrow the product picker to the active products of that kind. */
readonly categories = PRODUCT_TYPE_GROUPS;
categoryFilter = '';
typeFilter = '';
/** Ids of the products active today; null until read, or if the read failed (then nothing is hidden). */
private activeProductIds: Set<number> | null = null;
/** Every product from the template, before any filter. */
private allProducts: any[] = [];
/** Types offered for the chosen category; every type when no category is chosen. */
get typeOptions() {
return this.categoryFilter
? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? [])
: Object.values(PRODUCT_SUB_TYPES).flat();
}
/** The product picked in the form, with its category and type, for the template card. */
get selectedProduct(): any {
return this.catalog.get(this.loansAccountDetailsForm?.get('productId')?.value);
}
loanProductSelected = false; loanProductSelected = false;
/** Currency data. */ /** Currency data. */
protected productData: ReplaySubject<string[]> = new ReplaySubject<string[]>(1); protected productData: ReplaySubject<string[]> = new ReplaySubject<string[]>(1);
@@ -115,7 +141,8 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.maxDate = this.settingsService.maxFutureDate; this.maxDate = this.settingsService.maxFutureDate;
this.buildDependencies(); this.buildDependencies();
if (this.loansAccountTemplate) { if (this.loansAccountTemplate) {
this.productList = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name')); this.allProducts = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name'));
this.productList = this.allProducts;
if (this.loansAccountTemplate.loanProductId) { if (this.loansAccountTemplate.loanProductId) {
this.loansAccountDetailsForm.patchValue({ this.loansAccountDetailsForm.patchValue({
productId: this.loansAccountTemplate.loanProductId, productId: this.loansAccountTemplate.loanProductId,
@@ -136,6 +163,44 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.searchItem(); this.searchItem();
}); });
this.productData.next(this.productList.slice()); this.productData.next(this.productList.slice());
this.loadActiveProducts();
}
/**
* A new account can only be opened on a product that is active today, and the picker can be narrowed by category
* and type. Editing an existing account keeps every product, since its own product may have closed since.
*/
private loadActiveProducts(): void {
if (this.loanId) {
return;
}
this.catalog.activeProducts().subscribe((products) => {
this.activeProductIds = products.length > 0 ? new Set(products.map((product) => Number(product.id))) : null;
this.applyProductFilters();
});
}
onCategoryChange(): void {
// A type belongs to one category, so a type from another category cannot stay selected.
if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) {
this.typeFilter = '';
}
this.applyProductFilters();
}
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
applyProductFilters(): void {
this.productList = this.allProducts.filter((product: any) => {
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
return false;
}
const ext = this.catalog.classification(product.id);
if (this.categoryFilter && ext['productTypeGroup'] !== this.categoryFilter) {
return false;
}
return !this.typeFilter || ext['productTypeSubGroup'] === this.typeFilter;
});
this.searchItem();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -0,0 +1,48 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
/**
* Read-only view of the loan product catalogue for the loan account screens: which products are active today and
* what category and type each one is. Reads the normal product list once; the category and type of each product
* come from the classification the list carries (`advanceExtensions`).
*/
@Injectable({
providedIn: 'root'
})
export class LoanProductCatalogService {
private productsService = inject(ProductsService);
private byId = new Map<number, any>();
/**
* The products that are active today. Also remembers every product it saw, so `get` and `classification` can
* answer for them afterwards. An empty list on failure, so the picker never ends up with nothing to choose.
*/
activeProducts(): Observable<any[]> {
return this.productsService.getLoanProducts().pipe(
tap((products: any[]) => (products ?? []).forEach((product) => this.byId.set(Number(product.id), product))),
map((products: any[]) => (products ?? []).filter((product) => product?.status === 'loanProduct.active')),
catchError(() => of([]))
);
}
/** A product seen by `activeProducts`, or undefined before it ran or for an unknown id. */
get(productId: any): any {
return this.byId.get(Number(productId));
}
/** The product's category and type (`productTypeGroup`, `productTypeSubGroup`, ...), empty when it has none. */
classification(productId: any): Record<string, any> {
return this.get(productId)?.advanceExtensions ?? {};
}
}
@@ -52,7 +52,9 @@
} }
} @else { } @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span> <span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span> <span class="rrc-rate-now__since"
>{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span
>
} }
</div> </div>
} }
@@ -230,29 +232,11 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- Tracked by the row's own id, never by position. With $index, deleting a row shifted every @for (mapping of selection[role.configKey]; track $index; let i = $index) {
row after it up one, Angular reused the inputs bound to the old positions, and
ngModel wrote their values back into the shortened array - the row reappeared and
Save stayed grey. -->
@for (mapping of selection[role.configKey]; track mapping.rowId; let i = $index) {
<tr [class.rrc-map__row--invalid]="isRowInvalid(mapping)"> <tr [class.rrc-map__row--invalid]="isRowInvalid(mapping)">
<td> <td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field"> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<!-- The chart runs to a hundred accounts, so the list is typed into rather <mat-select [(ngModel)]="mapping.glAccountId" [disabled]="savingKey === role.configKey">
than scrolled. Cleared as the dropdown opens, so each one opens whole. -->
<mat-select
[(ngModel)]="mapping.glAccountId"
[disabled]="savingKey === role.configKey"
(openedChange)="accountSearch = ''"
>
<mat-option>
<ngx-mat-select-search
[(ngModel)]="accountSearch"
[ngModelOptions]="{ standalone: true }"
placeholderLabel="Search by GL code or name"
noEntriesFoundLabel="No account matches"
></ngx-mat-select-search>
</mat-option>
@for (o of availableAccounts(role, i); track o.id) { @for (o of availableAccounts(role, i); track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option> <mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
} }
@@ -7,9 +7,8 @@
*/ */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs'; import { catchError, forkJoin, of } from 'rxjs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service'; import { OrganizationService } from '../organization.service';
@@ -21,15 +20,6 @@ export interface ReserveMapping {
glAccountId: number; glAccountId: number;
weightPercentage: number; weightPercentage: number;
liabilityClass: string | null; liabilityClass: string | null;
/**
* Identity for the row, so the template can track rows by it rather than by position.
*
* Tracking by index made deletion impossible: removing a row shifts every row after it up one, so
* Angular reused the inputs that were bound to the old positions and ngModel wrote their values
* straight back into the shortened array. The row reappeared, the draft matched what was stored
* again, and Save stayed grey. Never sent to the backend.
*/
rowId: number;
} }
/** /**
@@ -53,9 +43,7 @@ export interface ReserveMapping {
styleUrls: ['./rbi-reserve-config.component.scss'], styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FormsModule, FormsModule
ReactiveFormsModule,
NgxMatSelectSearchModule
] ]
}) })
export class RbiReserveConfigComponent implements OnInit { export class RbiReserveConfigComponent implements OnInit {
@@ -92,33 +80,9 @@ export class RbiReserveConfigComponent implements OnInit {
/** Every reserve role the backend returned, before the form filter narrows it. */ /** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = []; private allRoles: any[] = [];
/** Hands each mapping row an identity the template can track it by. See ReserveMapping.rowId. */ /** Re-slices what is on screen. No round trip - the full set is already held. */
private nextRowId = 1;
/**
* What is typed into the search box inside an open account dropdown.
*
* One field for the whole screen rather than one per row: only one dropdown is open at a time, and
* it is cleared as each one opens, so a term left behind cannot narrow the next list a user opens
* without them seeing why it is short.
*/
accountSearch = '';
/**
* Re-slices what is on screen. No round trip - the full set is already held.
*
* The rows are rebuilt for whatever the new slice brings in. They used not to be: selection was
* filled once, for the heads the first slice happened to contain, so switching the filter showed
* the newly admitted heads with no rows at all - a card with a name and an empty body, reading as
* a head that had nothing mapped when it was simply never read.
*/
onFormFilterChange(): void { onFormFilterChange(): void {
this.glRoles = this.rolesForScreen(); this.glRoles = this.rolesForScreen();
for (const role of this.glRoles) {
if (!this.selection[role.configKey]) {
this.reloadSelection(role);
}
}
this.rebuildGroups(); this.rebuildGroups();
} }
@@ -266,7 +230,11 @@ export class RbiReserveConfigComponent implements OnInit {
for (const role of this.glRoles) { for (const role of this.glRoles) {
// Read back what is stored rather than re-derived: a weight or a class the administrator set // Read back what is stored rather than re-derived: a weight or a class the administrator set
// deliberately would otherwise be replaced by a default on the next save. // deliberately would otherwise be replaced by a default on the next save.
this.reloadSelection(role); this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null)
}));
} }
this.rebuildGroups(); this.rebuildGroups();
} }
@@ -320,16 +288,9 @@ export class RbiReserveConfigComponent implements OnInit {
); );
} }
/** /** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */
* A role with nothing mapped makes its return refuse to compute — surfaced, not hidden.
*
* Read from what is STORED, not from the draft on screen. The returns run off the saved mapping,
* so a row cleared but not yet saved changes nothing for them; counting it as unmapped made the
* banner announce that returns could not compute while they were still computing perfectly well.
* The unsaved edit is already shown by Save turning active.
*/
isUnmapped(role: any): boolean { isUnmapped(role: any): boolean {
return !(role.accounts || []).length; return !(this.selection[role.configKey] || []).length;
} }
get unmappedCount(): number { get unmappedCount(): number {
@@ -366,29 +327,11 @@ export class RbiReserveConfigComponent implements OnInit {
// a bank look compliant when it is not. // a bank look compliant when it is not.
this.selection[role.configKey] = [ this.selection[role.configKey] = [
...(this.selection[role.configKey] || []), ...(this.selection[role.configKey] || []),
{ { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }];
glAccountId: null as any,
weightPercentage: 100,
liabilityClass: role.classified ? 'TIME' : null,
rowId: this.nextRowId++
}];
} }
/**
* Drops a row and saves the head straight away.
*
* The bin used to clear the row on screen and leave it to Save, which read as the mapping having
* been removed when nothing had reached the server - the row came back on the next refresh. A bin
* is understood to delete, so it deletes: a row with nothing chosen in it never existed on the
* server and only has to go from the screen, and anything else is written through at once.
*/
removeMapping(role: any, index: number): void { removeMapping(role: any, index: number): void {
const rows = this.selection[role.configKey] || []; this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
const removed = rows[index];
this.selection[role.configKey] = rows.filter((_, i) => i !== index);
if (removed?.glAccountId) {
this.saveRole(role);
}
} }
/** /**
@@ -400,23 +343,7 @@ export class RbiReserveConfigComponent implements OnInit {
const rows = this.selection[role.configKey] || []; const rows = this.selection[role.configKey] || [];
const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`)); const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`));
const current = rows[index]; const current = rows[index];
const free = this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
// Typed into the search box above the list. Matched on code AND name, because an administrator
// looking for a head knows one or the other - the GL code off a trial balance, or the name off
// the balance sheet - and should not have to know which this list is sorted by.
const term = (this.accountSearch || '').trim().toLowerCase();
if (!term) {
return free;
}
// The chosen account stays in the list whatever is typed. Dropping it would leave mat-select
// with a value that matches no option, and the row would read as empty.
return free.filter(
(o) =>
o.id === current?.glAccountId ||
`${o.glCode}`.toLowerCase().includes(term) ||
`${o.name}`.toLowerCase().includes(term)
);
} }
/** A row that cannot be saved - no account chosen, or a portion outside 0-100. */ /** A row that cannot be saved - no account chosen, or a portion outside 0-100. */
@@ -452,16 +379,6 @@ export class RbiReserveConfigComponent implements OnInit {
.filter((e) => Math.abs(e.total - 100) > 0.0001); .filter((e) => Math.abs(e.total - 100) > 0.0001);
} }
/** Rebuilds one head's rows from what the server last returned, discarding the draft. */
private reloadSelection(role: any): void {
this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null),
rowId: this.nextRowId++
}));
}
saveRole(role: any): void { saveRole(role: any): void {
this.savingKey = role.configKey; this.savingKey = role.configKey;
this.errorMsg = ''; this.errorMsg = '';
@@ -481,11 +398,6 @@ export class RbiReserveConfigComponent implements OnInit {
catchError((err: any) => { catchError((err: any) => {
this.savingKey = null; this.savingKey = null;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
// Put the screen back to what the server holds. A failed save that left the draft showing
// would say the change had gone through while the server still had the old mapping - and
// the bin deletes immediately, so this is the only thing between a rejected delete and a
// row that looks gone but is not.
this.reloadSelection(role);
return of(null); return of(null);
}) })
) )
+1 -1
View File
@@ -62,7 +62,7 @@ export class AccountsFilterPipe implements PipeTransform {
account.status.code === 'savingsAccountStatusType.rejected' account.status.code === 'savingsAccountStatusType.rejected'
); );
}); });
} else if (status !== 'all') { } else {
accounts = accounts.filter((account: any) => { accounts = accounts.filter((account: any) => {
return ( return (
account.status.code !== 'savingsAccountStatusType.withdrawn.by.applicant' && account.status.code !== 'savingsAccountStatusType.withdrawn.by.applicant' &&
@@ -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> </div>
</mat-tab> </mat-tab>
<mat-tab> @if (isAdvanceProduct) {
<ng-template mat-tab-label> <mat-tab>
<i class="fas fa-cog lp-tab-icon"></i> <ng-template mat-tab-label>
{{ 'labels.inputs.SETTINGS' | translate }} <i class="fas fa-shield-alt lp-tab-icon"></i>
</ng-template> SECURITY &amp; LIEN
<div class="lp-tab-content"> </ng-template>
<mifosx-loan-product-settings-step <div class="lp-tab-content">
[loanProductsTemplate]="loanProductsTemplate" <mifosx-loan-product-security-lien-step
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')" [loanProductsTemplate]="loanProductsTemplate"
(advancePaymentStrategy)="advancePaymentStrategy($event)" [productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
></mifosx-loan-product-settings-step> [productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
</div> ></mifosx-loan-product-security-lien-step>
</mat-tab> </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) { @if (isAdvancedPaymentStrategy) {
<mat-tab> <mat-tab>
@@ -90,15 +239,20 @@
</mat-tab> </mat-tab>
} }
<mat-tab> @if (!isAdvanceProduct) {
<ng-template mat-tab-label> <mat-tab>
<i class="fas fa-sliders-h lp-tab-icon"></i> <ng-template mat-tab-label>
{{ 'labels.inputs.TERMS' | translate }} <i class="fas fa-sliders-h lp-tab-icon"></i>
</ng-template> {{ 'labels.inputs.TERMS' | translate }}
<div class="lp-tab-content"> </ng-template>
<mifosx-loan-product-terms-step [loanProductsTemplate]="loanProductsTemplate"></mifosx-loan-product-terms-step> <div class="lp-tab-content">
</div> <mifosx-loan-product-terms-step
</mat-tab> [loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-terms-step>
</div>
</mat-tab>
}
-->
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -109,11 +263,12 @@
<mifosx-loan-product-charges-step <mifosx-loan-product-charges-step
[loanProductsTemplate]="loanProductsTemplate" [loanProductsTemplate]="loanProductsTemplate"
[currencyCode]="loanProductCurrencyForm.get('currencyCode')" [currencyCode]="loanProductCurrencyForm.get('currencyCode')"
[multiDisburseLoan]="loanProductSettingsForm.get('multiDisburseLoan')" [multiDisburseLoan]="loanProductSettingsForm?.get('multiDisburseLoan') ?? multiDisburseFallback"
></mifosx-loan-product-charges-step> ></mifosx-loan-product-charges-step>
</div> </div>
</mat-tab> </mat-tab>
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) { @if (isAdvancedPaymentStrategy) {
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -134,6 +289,7 @@
</div> </div>
</mat-tab> </mat-tab>
} }
-->
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -13,11 +13,22 @@ import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */ /** Custom Components */
import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component'; 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 { 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 { 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 { 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 { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component';
import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component'; import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component';
import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component'; import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component';
import { LoanProductLoanSecurityStepComponent } from '../loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component';
import { LoanProductLoanAmountTenureStepComponent } from '../loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component';
import { LoanProductLoanRepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component';
import { LoanProductLoanInterestStepComponent } from '../loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component';
import { LoanProductLoanPrepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component';
/** Custom Services */ /** Custom Services */
import { ProductsService } from 'app/products/products.service'; import { ProductsService } from 'app/products/products.service';
@@ -34,7 +45,7 @@ import {
import { Accounting } from 'app/core/utils/accounting'; import { Accounting } from 'app/core/utils/accounting';
import { StringEnumOptionData } from '../../../shared/models/option-data.model'; 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 { 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component'; 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'; 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, FaIconComponent,
LoanProductDetailsStepComponent, LoanProductDetailsStepComponent,
LoanProductCurrencyStepComponent, LoanProductCurrencyStepComponent,
LoanProductSettingsStepComponent, LoanProductSecurityLienStepComponent,
LoanProductInterestRefundStepComponent, LoanProductAdvanceLimitTenureStepComponent,
LoanProductPaymentStrategyStepComponent, LoanProductAdvanceInterestStepComponent,
LoanProductTermsStepComponent, 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, LoanProductChargesStepComponent,
LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductAccountingStepComponent, LoanProductAccountingStepComponent,
LoanProductPreviewStepComponent LoanProductPreviewStepComponent
] ]
@@ -73,12 +96,39 @@ export class CreateLoanProductComponent implements OnInit {
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent; @ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
@ViewChild(LoanProductCurrencyStepComponent, { static: true }) @ViewChild(LoanProductCurrencyStepComponent, { static: true })
loanProductCurrencyStep: LoanProductCurrencyStepComponent; 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 }) @ViewChild(LoanProductInterestRefundStepComponent, { static: true })
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent; loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true }) @ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent; loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
@ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent; // Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically.
@ViewChild(LoanProductSettingsStepComponent, { static: true }) @ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent)
loanProductSettingsStep: LoanProductSettingsStepComponent; loanProductSettingsStep: LoanProductSettingsStepComponent;
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent; @ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
@ViewChild(LoanProductAccountingStepComponent, { static: true }) @ViewChild(LoanProductAccountingStepComponent, { static: true })
@@ -143,7 +193,23 @@ export class CreateLoanProductComponent implements OnInit {
} }
get loanProductTermsForm() { 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) { advancePaymentStrategy(value: string) {
@@ -231,47 +297,103 @@ export class CreateLoanProductComponent implements OnInit {
} }
get loanProductSettingsForm() { get loanProductSettingsForm() {
return this.loanProductSettingsStep.loanProductSettingsForm; // Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductSettingsStep?.loanProductSettingsForm;
} }
get loanProductAccountingForm() { get loanProductAccountingForm() {
return this.loanProductAccountingStep.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 { get totalTabs(): number {
let tabs = 6; // details, currency, settings, terms, charges, accounting let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income 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 if (this.loanProductFormValid) tabs += 1; // preview
return tabs; return tabs;
} }
get loanProductFormValid() { 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 ( return (
this.loanProductDetailsForm.valid && base &&
this.loanProductCurrencyForm.valid && !!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
this.loanProductTermsForm.valid && !!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
this.loanProductSettingsForm.valid && !!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
this.loanIncomeCapitalizationForm.valid && !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
this.loanProductAccountingForm.valid !!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
); (!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
} else {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanProductAccountingForm.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() { 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 = { const loanProduct = {
...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductDetailsStep.loanProductDetails,
...this.loanProductCurrencyStep.loanProductCurrency, ...this.loanProductCurrencyStep.loanProductCurrency,
...this.loanProductTermsStep.loanProductTerms, ...(this.loanProductTermsStep?.loanProductTerms ?? {}),
...this.loanProductSettingsStep.loanProductSettings, ...(this.loanProductSettingsStep?.loanProductSettings ?? {}),
...this.loanProductChargesStep.loanProductCharges, ...this.loanProductChargesStep.loanProductCharges,
...this.loanProductAccountingStep.loanProductAccounting ...this.loanProductAccountingStep.loanProductAccounting
}; };
@@ -302,6 +424,340 @@ export class CreateLoanProductComponent implements OnInit {
} }
} }
return loanProduct; return loanProduct;
*/
}
/**
* Builds the create payload for a Loans A/C (EMI) product: the Loans steps mapped onto Fineract's core
* product fields (so the standard product saves). The Loans-only options that have no core field yet
* (eligibility, collateral rules, prepayment, repayment mode, floating reset, register link) stay UI-only
* and are not sent — an unknown key would be rejected by checkForUnsupportedParameters.
*/
private assembleLoanProduct(): any {
const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const at = this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenure;
const rp = this.loanProductLoanRepaymentStep.loanProductLoanRepayment;
const intr = this.loanProductLoanInterestStep.loanProductLoanInterest;
const sec = this.loanProductLoanSecurityStep.loanProductLoanSecurity;
const pp = this.loanProductLoanPrepaymentStep.loanProductLoanPrepayment;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
const stepper = this.loanProductLoanRepaymentStep;
const interestStep = this.loanProductLoanInterestStep;
const frequency = stepper.frequencyOptions.find((f) => f.id === rp.frequency)?.fineractId ?? 2;
const daysInYear = interestStep.daysInYearOptions.find((o) => o.id === intr.daysInYear)?.fineractId ?? 1;
const daysInMonth = interestStep.daysInMonthOptions.find((o) => o.id === intr.daysInMonth)?.fineractId ?? 1;
const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
const minTenure = num(at.minTenure);
const minRate = num(intr.minRate);
const multiTranche = at.disbursementType === 'multiple';
const moratorium = num(rp.moratoriumMonths);
const payload: any = {
// identity + currency
name: d.name,
shortName: d.shortName,
description: d.description,
startDate: d.startDate,
closeDate: d.closeDate,
currencyCode: cur.currencyCode,
digitsAfterDecimal: cur.digitsAfterDecimal,
inMultiplesOf: cur.inMultiplesOf ?? 1,
// amount -> principal
minPrincipal: num(at.minAmount),
principal: num(at.defaultAmount) ?? num(at.minAmount),
maxPrincipal: num(at.maxAmount),
// tenure (months) -> number of repayments
minNumberOfRepayments: minTenure,
numberOfRepayments: num(at.defaultTenure) ?? minTenure,
maxNumberOfRepayments: num(at.maxTenure),
repaymentEvery: num(rp.repaidEvery) ?? 1,
repaymentFrequencyType: frequency,
graceOnPrincipalPayment: moratorium && moratorium > 0 ? moratorium : undefined,
// rate
minInterestRatePerPeriod: minRate,
interestRatePerPeriod: num(intr.defaultRate) ?? minRate,
maxInterestRatePerPeriod: num(intr.maxRate),
interestRateFrequencyType: 3, // per year
// amortization / calculation
amortizationType: rp.repaymentType === 'equal_principal' ? 0 : 1, // 0 equal principal, 1 equal installments
interestType: intr.calcMethod === 'flat' ? 1 : 0, // 0 declining balance, 1 flat
interestCalculationPeriodType: intr.calcPeriod === 'daily' ? 0 : 1, // 0 daily, 1 same as repayment period
transactionProcessingStrategyCode: defaultStrategy,
daysInYearType: daysInYear,
daysInMonthType: daysInMonth,
isInterestRecalculationEnabled: false,
// disbursement
multiDisburseLoan: multiTranche,
maxTrancheCount: multiTranche ? num(at.maxTranches) : undefined,
// NPA / delinquency map onto core product fields
overdueDaysForNPA: num(pn.npaDays),
graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
inArrearsTolerance: num(pn.inArrearsTolerance),
delinquencyBucketId: pn.delinquencyBucketId || undefined,
// accounting rule + GL mappings + charges (all standard, whitelisted)
...this.loanProductAccountingStep.loanProductAccounting,
...this.loanProductChargesStep.loanProductCharges,
// Details-step satellite params (same whitelist the Advances path uses)
productTypeGroup: d.productTypeGroup,
productTypeSubGroup: d.productTypeSubGroup,
glAccount: d.glAccount || undefined,
loanProductCategory: d.loanProductCategory,
prioritySector: d.prioritySector,
cibilAccountType: d.cibilAccountType,
kfsEnabled: d.kfsEnabled,
aprEnabled: d.aprEnabled,
// Loans A/C (EMI) fields kept on the product satellite (same whitelist as the Advances fields)
margin: num(sec.margin),
ltv: sec.ltv,
securityType: sec.securityType,
repaymentType: rp.repaymentType,
appropriationOrder: rp.appropriationOrder,
interestCalcMethod: intr.calcMethod,
spread: num(intr.spread),
lockInMonths: num(pp.lockInMonths),
interestOnPreclosure: pp.interestOnPreclosure,
penalApplicable: pn.penalApplicable,
penalRate: num(pn.penalRate),
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
interestAfterNpa: pn.interestAfterNpa,
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
securityMandatory: sec.securityMandatory,
guarantorsRequired: num(sec.guarantorsRequired),
coApplicant: sec.coApplicant,
thirdPartySecurity: sec.thirdPartySecurity,
legalOpinionRequired: sec.legalOpinionRequired,
propertyInsuranceRequired: sec.propertyInsuranceRequired,
registerCharge: sec.registerCharge,
releaseSecurityOnClosure: sec.autoReleaseOnClosure,
allowMultipleSecurities: sec.allowMultipleSecurities,
allowThirdPartyDeposit: sec.allowThirdPartyDeposit,
minAge: num(at.minAge),
maxAgeAtMaturity: num(at.maxAgeAtMaturity),
minCibil: num(at.minCibil),
minMonthlyIncome: num(at.minMonthlyIncome),
maxFoir: num(at.maxFoir),
maxLoansPerCustomer: num(at.maxLoansPerCustomer),
disbursementType: at.disbursementType,
disbursementMode: at.disbursementMode,
undisbursedInterest: at.undisbursedInterest,
preEmiInterest: at.preEmiInterest,
emiDueDay: rp.emiDueDay,
holidayRule: rp.holidayRule,
moratoriumMonths: num(rp.moratoriumMonths),
moratoriumInterest: rp.moratoriumInterest,
variableInstallments: rp.variableInstallments,
repaymentMode: rp.repaymentMode,
linkedAccountType: rp.linkedAccountType,
pdcCollection: rp.pdcCollection,
mandateRequired: rp.mandateRequired,
advanceEmi: rp.advanceEmi,
rateType: intr.interestType,
registerParticular: intr.registerParticular,
applyCibilAdjustment: intr.applyCibilAdjustment,
rateResolvedAt: intr.rateResolvedAt,
calcPeriod: intr.calcPeriod,
interestStartsFrom: intr.interestStartsFrom,
compounding: intr.compounding,
benchmark: intr.benchmark,
resetFrequency: intr.resetFrequency,
resetChange: intr.resetChange,
partPrepaymentAllowed: pp.partPrepaymentAllowed,
minPartPayment: num(pp.minPartPayment),
allowedOn: pp.allowedOn,
partPaymentsPerYear: pp.partPaymentsPerYear,
partPrepaymentMode: pp.partPrepaymentMode,
foreclosureFloatingIndividual: num(pp.foreclosureFloatingIndividual),
foreclosureFixedIndividual: num(pp.foreclosureFixedIndividual),
foreclosureNonIndividual: num(pp.foreclosureNonIndividual),
foreclosureTakeover: num(pp.foreclosureTakeover),
foreclosureQuoteValidDays: num(pp.foreclosureQuoteValidDays),
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
nocLetter: pp.nocLetter,
restructuring: pp.restructuring,
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
topUpAllowed: pp.topUpAllowed,
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
};
// Drop undefined keys so we never send an unresolved value.
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload;
}
/**
* 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() { 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"> <form [formGroup]="loanProductDetailsForm">
<div class="lpd-section"> <!-- Product Type — picked first; it decides which sections the rest of the wizard shows -->
<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 -->
<div class="lpd-section"> <div class="lpd-section">
<div class="lpd-section__header"> <div class="lpd-section__header">
<i class="fas fa-sitemap"></i> <i class="fas fa-sitemap"></i>
<h3>Product Classification</h3> <h3>Product Type</h3>
</div> </div>
<div class="lpd-grid"> <mat-radio-group formControlName="productTypeGroup" class="lpd-type-options">
<mat-form-field appearance="outline" class="lpd-field"> @for (group of productTypeGroups; track group.id) {
<mat-label>Product Type Group</mat-label> <mat-radio-button [value]="group.id" class="lpd-type-option">
<mat-select formControlName="productTypeGroup"> <span class="lpd-type-option__label">{{ group.label }}</span>
@for (group of productTypeGroups; track group.id) { <span class="lpd-type-option__hint">{{ group.hint }}</span>
<mat-option [value]="group.id">{{ group.label }}</mat-option> </mat-radio-button>
} }
</mat-select> </mat-radio-group>
</mat-form-field>
@if (loanProductDetailsForm.get('productTypeGroup')?.value) { @if (loanProductDetailsForm.get('productTypeGroup')?.value) {
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field"> <mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label> <mat-label>Product Sub Type</mat-label>
<mat-select formControlName="glAccount"> <mat-select formControlName="productTypeSubGroup" [required]="!isExistingProduct">
@for (acc of availableGlAccounts; track acc.id) { @for (subGroup of availableSubGroups; track subGroup.id) {
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option> <mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
} }
</mat-select> </mat-select>
<mat-error>GL Account is required</mat-error> <mat-error>Product Sub Type is <strong>required</strong></mat-error>
</mat-form-field> </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> </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> </form>
@@ -61,6 +61,57 @@ $gray-900: #202124;
&--full { &--full {
grid-column: 1 / -1; 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 { .lpd-checkbox-row {
@@ -15,7 +15,9 @@ import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types';
@Component({ @Component({
selector: 'mifosx-loan-product-details-step', selector: 'mifosx-loan-product-details-step',
@@ -24,7 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTooltip, MatTooltip,
CdkTextareaAutosize CdkTextareaAutosize,
MatRadioGroup,
MatRadioButton
] ]
}) })
export class LoanProductDetailsStepComponent implements OnInit { export class LoanProductDetailsStepComponent implements OnInit {
@@ -39,6 +43,14 @@ export class LoanProductDetailsStepComponent implements OnInit {
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10)); maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10));
// Start Date policy: a new product must start on the current day — the picker allows only today
// (neither past nor future). Widened for an existing product so its saved start date stays valid.
startDateMin: Date = new Date();
startDateMax: Date = new Date();
// Close (End) Date accepts only future dates — default min is tomorrow; widened for an existing product.
closeDateMin: Date = new Date();
/** /**
* @param {FormBuilder} formBuilder Form Builder. * @param {FormBuilder} formBuilder Form Builder.
* @param {Dates} dateUtils Date Utils. * @param {Dates} dateUtils Date Utils.
@@ -50,90 +62,133 @@ export class LoanProductDetailsStepComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
// Product Type is not persisted by the backend yet, so an existing product comes back without one.
// Only a new product is made to pick it first; editing must not hide or block the rest of the form.
this.isExistingProduct = !!this.loanProductsTemplate?.id;
if (!this.isExistingProduct) {
this.loanProductDetailsForm.get('productTypeGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('productTypeSubGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('glAccount').setValidators(Validators.required);
// A new product's short name is exactly two capital letters. Saved products keep the old 4-character
// rule, so an existing short name such as 'PW16' does not stop the product from being edited.
this.loanProductDetailsForm.get('shortName').setValidators([
Validators.required,
Validators.pattern(/^[A-Z]{2}$/)
]);
this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('shortName').updateValueAndValidity({ emitEvent: false });
}
// A loan / advance is an asset of the bank, so its ledger GL is always an asset account.
this.assetAccountOptions = this.loanProductsTemplate?.accountingMappingOptions?.assetAccountOptions ?? [];
// Start Date: default to today for a new product and lock the picker to the current day.
// An existing product keeps the wider range so its saved date remains selectable/valid.
if (this.isExistingProduct) {
this.startDateMin = this.minDate;
this.startDateMax = this.maxDate;
this.closeDateMin = this.minDate;
} else {
this.startDateMin = new Date();
this.startDateMax = new Date();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
this.closeDateMin = tomorrow;
}
this.loanProductDetailsForm.patchValue({ this.loanProductDetailsForm.patchValue({
name: this.loanProductsTemplate.name, name: this.loanProductsTemplate.name,
shortName: this.loanProductsTemplate.shortName, shortName: this.loanProductsTemplate.shortName,
description: this.loanProductsTemplate.description, description: this.loanProductsTemplate.description,
startDate: this.loanProductsTemplate.startDate && new Date(this.loanProductsTemplate.startDate), startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate) closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
}); });
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
this.loanProductDetailsForm.patchValue({
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false,
subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false,
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
});
} }
/** Product Classification data */ /** True when editing a saved product; see ngOnInit. */
readonly productTypeGroups = [ isExistingProduct = false;
{ id: 'loans', label: 'Loan' },
{ id: 'advanced', label: 'Advanced' } /** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */
/** Category and type lists are shared with the product list and the loan account product picker. */
readonly productTypeGroups = PRODUCT_TYPE_GROUPS;
readonly productTypeSubGroups = PRODUCT_SUB_TYPES;
/** Regulatory Classification options (UI-only; RBI / bureau reporting). */
readonly loanProductCategoryOptions = [
{ id: 'advances_deposits', label: 'Advances against deposits' },
{ id: 'gold_loan', label: 'Gold loan' },
{ id: 'agriculture', label: 'Agriculture' },
{ id: 'msme', label: 'MSME' },
{ id: 'personal', label: 'Personal / retail' },
{ id: 'housing', label: 'Housing' },
{ id: 'other', label: 'Other' }
]; ];
readonly productTypeSubGroups: Record<string, { id: string; label: string }[]> = { readonly prioritySectorOptions = [
loans: [ { id: 'yes', label: 'Yes' },
{ id: 'emi', label: 'EMI' }, { id: 'no', label: 'No' }
{ id: 'cc', label: 'CC (Cash Credit)' }, ];
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'warehouse', label: 'Warehouse' },
{ id: 'gold', label: 'Gold' }
],
advanced: [
{ id: 'fd', label: 'FD (Fixed Deposit)' },
{ id: 'rd', label: 'RD (Recurring Deposit)' }
]
};
/** Default GL per product type group */ readonly cibilAccountTypeOptions = [
private readonly glDefaultMap: Record<string, string> = { { id: 'loan_bank_deposits', label: 'Loan against Bank Deposits' },
loans: 'asset', { id: 'gold_loan', label: 'Gold Loan' },
advanced: 'asset' { id: 'overdraft', label: 'Overdraft' },
}; { id: 'business_loan', label: 'Business Loan' },
{ id: 'other', label: 'Other' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
/** The rest of the Details form stays hidden on a new product until its type and sub type are picked. */
get isProductTypeChosen(): boolean {
if (this.isExistingProduct) {
return true;
}
const value = this.loanProductDetailsForm.value;
return !!value.productTypeGroup && !!value.productTypeSubGroup;
}
get availableSubGroups() { get availableSubGroups() {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return group ? (this.productTypeSubGroups[group] ?? []) : []; return group ? (this.productTypeSubGroups[group] ?? []) : [];
} }
get glOptions(): string[] { /**
return [ * Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are
'asset', * generated from. Separate from the Accounting tab mappings, which drive the journal entries.
'liability' */
];
}
assetAccountOptions: any[] = []; assetAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
get availableGlAccounts(): any[] {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
if (type === 'asset') return this.assetAccountOptions;
if (type === 'liability') return this.liabilityAccountOptions;
return [];
}
get glClassificationLabel(): string {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
return type === 'liability' ? 'Liability' : 'Asset';
}
get selectedGroupLabel(): string { get selectedGroupLabel(): string {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return this.productTypeGroups.find((g) => g.id === group)?.label ?? ''; return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
} }
readonly yesNoOptions = [ /**
{ value: true, label: 'YES' }, * A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything
{ value: false, label: 'NO' } * that is not a letter is dropped, and only the first two letters are kept. Trimming here rather than with
]; * maxlength means a paste like 'g1old' still yields 'GO' instead of being cut to 'g1' first.
*/
normalizeShortName(event: Event) {
if (this.isExistingProduct) {
return;
}
// Read from the input itself: this handler can run before the form control has taken the new value.
const typed = (event.target as HTMLInputElement).value ?? '';
const cleaned = typed
.toUpperCase()
.replace(/[^A-Z]/g, '')
.slice(0, 2);
if (cleaned !== typed) {
this.loanProductDetailsForm.get('shortName').setValue(cleaned);
}
}
createLoanProductDetailsForm() { createLoanProductDetailsForm() {
this.loanProductDetailsForm = this.formBuilder.group({ this.loanProductDetailsForm = this.formBuilder.group({
@@ -150,21 +205,26 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: [''], closeDate: [''],
productTypeGroup: [''], productTypeGroup: [''],
productTypeSubGroup: [''], productTypeSubGroup: [''],
glClassification: ['asset'],
glAccount: [''], glAccount: [''],
debitIntCalculation: [false], // Regulatory Classification (UI-only; stripped in buildPayload)
separatePenalInterest: [false], loanProductCategory: ['advances_deposits'],
creditIntCalculation: [false], prioritySector: ['no'],
subAccountTypeBalance: [false], cibilAccountType: ['loan_bank_deposits'],
finalIntRounding: [false], kfsEnabled: ['YES'],
roundingUpTo: [null] aprEnabled: ['YES']
}); });
// Auto-set GL based on product type group // A sub type belongs to one group, so changing the group clears it.
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => { this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => {
const defaultVal = this.glDefaultMap[group] ?? 'asset'; this.loanProductDetailsForm.get('productTypeSubGroup')?.setValue('');
this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal); });
this.loanProductDetailsForm.get('glAccount')?.setValue('');
// Picking a sub type pre-fills the Short Name with its code; the user can still change it.
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => {
const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId);
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
}
}); });
} }
@@ -0,0 +1,150 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductLoanAmountTenureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-coins"></i>
<h3>Loan Amount</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Amount (₹)</mat-label>
<input matInput type="number" formControlName="minAmount" min="0" required />
<mat-error>Minimum Amount is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Amount (₹)</mat-label>
<input matInput type="number" formControlName="defaultAmount" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Amount (₹)</mat-label>
<input matInput type="number" formControlName="maxAmount" min="0" required />
<mat-error>Maximum Amount is <strong>required</strong></mat-error>
</mat-form-field>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Each loan is sanctioned within these bounds. The amount is fixed at sanction. It is not a limit that can be
drawn and repaid again.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Tenure</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" min="1" required />
<mat-error>Minimum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" min="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" min="1" required />
<mat-hint>Age at maturity cap comes from Borrower Eligibility below</mat-hint>
<mat-error>Maximum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user"></i>
<h3>Borrower Eligibility</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Age (years)</mat-label>
<input matInput type="number" formControlName="minAge" min="18" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Age at Maturity (years)</mat-label>
<input matInput type="number" formControlName="maxAgeAtMaturity" min="18" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum CIBIL Score</mat-label>
<input matInput type="number" formControlName="minCibil" min="300" max="900" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Monthly Income (₹)</mat-label>
<input matInput type="number" formControlName="minMonthlyIncome" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum FOIR / DBR (%)</mat-label>
<input matInput type="number" formControlName="maxFoir" min="0" max="100" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Loans per Customer</mat-label>
<input matInput type="number" formControlName="maxLoansPerCustomer" min="1" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrow-right"></i>
<h3>Disbursement</h3>
</div>
<mat-radio-group formControlName="disbursementType" class="adv-type-options">
<mat-radio-button value="single" class="adv-type-option">
<span class="adv-type-option__label">Single disbursement</span>
<span class="adv-type-option__hint">Full amount paid out once.</span>
</mat-radio-button>
<mat-radio-button value="multiple" class="adv-type-option">
<span class="adv-type-option__label">Multiple tranches</span>
<span class="adv-type-option__hint">Paid out in stages, for example as construction progresses.</span>
</mat-radio-button>
</mat-radio-group>
<div class="adv-grid adv-grid--3">
@if (isMultiTranche) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tranches</mat-label>
<input matInput type="number" formControlName="maxTranches" min="2" />
<mat-error>At least <strong>2</strong> tranches</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Disbursement Mode</mat-label>
<mat-select formControlName="disbursementMode">
@for (opt of disbursementModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Undisbursed Amount</mat-label>
<mat-select formControlName="undisbursedInterest">
@for (opt of undisbursedInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="preEmiInterest">
Pre-EMI interest is charged on each disbursed tranche until the first full EMI
</mat-checkbox>
</div>
</div>
<div class="adv-note">
<i class="fas fa-info-circle"></i>
<span>
<strong>Hidden for Loans A/C:</strong> Facility Type (Demand / Overdraft), Drawing power, Limit renewal, Cheque
book.
</span>
</div>
</form>
@@ -0,0 +1,106 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure,
* borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product
* fields; eligibility is UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-amount-tenure-step',
templateUrl: './loan-product-loan-amount-tenure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanAmountTenureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanAmountTenureForm: UntypedFormGroup;
readonly disbursementModeOptions = [
{ id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' },
{ id: 'transfer', label: 'Transfer to A/C only' },
{ id: 'dd', label: 'Demand draft only' }
];
readonly undisbursedInterestOptions = [
{ id: 'none', label: 'None — interest only on disbursed' },
{ id: 'commitment', label: 'Commitment charge on undisbursed' }
];
constructor() {
this.loanProductLoanAmountTenureForm = this.formBuilder.group({
minAmount: [
'',
[
Validators.required,
Validators.min(0)
]
],
defaultAmount: [''],
maxAmount: [
'',
[
Validators.required,
Validators.min(0)
]
],
minTenure: [
12,
[
Validators.required,
Validators.min(1)
]
],
defaultTenure: [
'',
Validators.min(1)
],
maxTenure: [
360,
[
Validators.required,
Validators.min(1)
]
],
minAge: [21],
maxAgeAtMaturity: [70],
minCibil: [650],
minMonthlyIncome: [''],
maxFoir: [50],
maxLoansPerCustomer: [1],
disbursementType: ['single'],
maxTranches: [
2,
Validators.min(2)
],
disbursementMode: ['transfer_dd_vendor'],
undisbursedInterest: ['none'],
preEmiInterest: [true]
});
}
get isMultiTranche(): boolean {
return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple';
}
get loanProductLoanAmountTenure() {
return this.loanProductLoanAmountTenureForm.getRawValue();
}
}
@@ -0,0 +1,161 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductLoanInterestForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Interest Type</h3>
</div>
<mat-radio-group formControlName="interestType" class="adv-type-options adv-type-options--3">
<mat-radio-button value="fixed" class="adv-type-option">
<span class="adv-type-option__label">Fixed</span>
<span class="adv-type-option__hint">Same rate for the whole tenure</span>
</mat-radio-button>
<mat-radio-button value="floating" class="adv-type-option">
<span class="adv-type-option__label">Floating</span>
<span class="adv-type-option__hint">Linked to a benchmark; resets periodically</span>
</mat-radio-button>
<mat-radio-button value="hybrid" class="adv-type-option">
<span class="adv-type-option__label">Hybrid</span>
<span class="adv-type-option__hint">Fixed for a period, then floating</span>
</mat-radio-button>
</mat-radio-group>
<div style="height: 20px"></div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table-cells"></i>
<h3>Rate Source</h3>
<span class="adv-section__hint">Centralised — changes flow to all loans on this product</span>
</div>
<div style="padding: 16px 0 8px">
<mifosx-register-rate-view
[productSubType]="productSubType"
[glAccountId]="glAccountId"
(selectionChange)="onRegisterSelection($event)"
></mifosx-register-rate-view>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Apply CIBIL Adjustment</mat-label>
<mat-select formControlName="applyCibilAdjustment">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Spread over Register Rate (%)</mat-label>
<input matInput type="number" formControlName="spread" min="0" max="20" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Rate Resolved At</mat-label>
<mat-select formControlName="rateResolvedAt">
@for (opt of rateResolvedAtOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-chart-simple"></i>
<h3>Calculation</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Method</mat-label>
<mat-select formControlName="calcMethod">
@for (opt of calcMethodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="daysInYear">
@for (opt of daysInYearOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Month</mat-label>
<mat-select formControlName="daysInMonth">
@for (opt of daysInMonthOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Period</mat-label>
<mat-select formControlName="calcPeriod">
@for (opt of calcPeriodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Starts From</mat-label>
<mat-select formControlName="interestStartsFrom">
@for (opt of interestStartOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Compounding</mat-label>
<mat-select formControlName="compounding">
@for (opt of compoundingOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (isFloatingOrHybrid) {
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-rotate"></i>
<h3>Floating Rate Reset</h3>
<span class="adv-section__hint">Shown only when the type is Floating or Hybrid</span>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Benchmark</mat-label>
<mat-select formControlName="benchmark">
@for (opt of benchmarkOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reset Frequency</mat-label>
<mat-select formControlName="resetFrequency">
@for (opt of resetFrequencyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>On Reset, Change</mat-label>
<mat-select formControlName="resetChange">
@for (opt of resetChangeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form>
@@ -0,0 +1,188 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component';
/**
* Interest step for a Loans A/C (EMI) product (step 6). The rate normally comes from the central
* Loan Interest Rate Register (amount slab + CIBIL adjustment); the min / default / max rate and the
* calculation method feed Fineract's core product fields. Register link, spread and floating reset
* are UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-interest-step',
templateUrl: './loan-product-loan-interest-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
RegisterRateViewComponent
]
})
export class LoanProductLoanInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanInterestForm: UntypedFormGroup;
/** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */
@Input() productSubType = '';
@Input() glAccountId: number | string | null = null;
/** The register row the product takes its rate from; null while the register has no row for it. */
registerSelection: RegisterRateSelection | null = null;
/** The 16 default particulars of the Loan Interest Rate Register. */
readonly registerParticulars = [
'Housing Loan (Purchase/Construction)',
'Home Repair/Renovation',
'Personal Loan (Unsecured)',
'Personal Loan (Secured/Property)',
'Two Wheeler Loan',
'Car Loan (New)',
'Used Car/Vehicle Loan',
'Commercial Vehicle Loan',
'Gold Loan',
'Business Term Loan',
'Machinery & Equipment Loan',
'Cash Credit',
'Overdraft',
'Agriculture Loan',
'Loan against FDR',
'Loan against RD'
];
readonly yesNoOptions = [
'YES',
'NO'
];
readonly rateResolvedAtOptions = [
{ id: 'sanction', label: 'Sanction' },
{ id: 'disbursement', label: 'First disbursement' }
];
readonly calcMethodOptions = [
{ id: 'declining', label: 'Declining Balance' },
{ id: 'flat', label: 'Flat' }
];
/** `fineractId` maps to Fineract's daysInYearType (1 = actual). */
readonly daysInYearOptions = [
{ id: 'actual', label: 'Actual (365 / 366)', fineractId: 1 },
{ id: '365', label: '365', fineractId: 365 },
{ id: '360', label: '360', fineractId: 360 }
];
/** `fineractId` maps to Fineract's daysInMonthType (1 = actual). */
readonly daysInMonthOptions = [
{ id: 'actual', label: 'Actual', fineractId: 1 },
{ id: '30', label: '30', fineractId: 30 }
];
readonly calcPeriodOptions = [
{ id: 'same', label: 'Same as repayment period' },
{ id: 'daily', label: 'Daily' }
];
readonly interestStartOptions = [
{ id: 'disbursement', label: 'Disbursement date' },
{ id: 'first_emi', label: 'First EMI date' }
];
readonly compoundingOptions = [
{ id: 'none', label: 'None (simple on outstanding)' },
{ id: 'monthly', label: 'Monthly' }
];
readonly benchmarkOptions = [
{ id: 'repo', label: 'Repo / MCLR / Bank base rate' },
{ id: 'eblr', label: 'External benchmark (EBLR)' }
];
readonly resetFrequencyOptions = [
{ id: 'quarterly', label: 'Quarterly' },
{ id: 'half_yearly', label: 'Half-yearly' },
{ id: 'yearly', label: 'Yearly' }
];
readonly resetChangeOptions = [
{ id: 'tenure', label: 'Tenure (EMI stays constant)' },
{ id: 'emi', label: 'EMI (tenure stays constant)' }
];
constructor() {
this.loanProductLoanInterestForm = this.formBuilder.group({
interestType: ['fixed'],
// The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created.
registerParticular: [
'',
Validators.required
],
applyCibilAdjustment: ['YES'],
spread: [
0,
[
Validators.min(0),
Validators.max(20)
]
],
rateResolvedAt: ['sanction'],
// Filled from the register row; never typed in.
minRate: [''],
defaultRate: [''],
maxRate: [''],
calcMethod: ['declining'],
daysInYear: ['actual'],
daysInMonth: ['actual'],
calcPeriod: ['same'],
interestStartsFrom: ['disbursement'],
compounding: ['none'],
benchmark: ['repo'],
resetFrequency: ['quarterly'],
resetChange: ['tenure']
});
}
/**
* The register row decides the rate limits. With no row for the product the register particular stays empty,
* which keeps this step, and so the whole product, invalid until the product has been added to the register.
*/
onRegisterSelection(selection: RegisterRateSelection | null): void {
this.registerSelection = selection;
const form = this.loanProductLoanInterestForm;
if (selection) {
form.patchValue(
{
registerParticular: selection.particular,
minRate: selection.minRate ?? '',
maxRate: selection.maxRate ?? '',
defaultRate: selection.defaultRate ?? ''
},
{ emitEvent: false }
);
} else {
form.patchValue({ registerParticular: '', minRate: '', maxRate: '', defaultRate: '' }, { emitEvent: false });
}
form.updateValueAndValidity({ emitEvent: false });
}
/** Reset options only apply to a floating or hybrid rate. */
get isFloatingOrHybrid(): boolean {
const type = this.loanProductLoanInterestForm.get('interestType').value;
return type === 'floating' || type === 'hybrid';
}
get loanProductLoanInterest() {
return this.loanProductLoanInterestForm.getRawValue();
}
}
@@ -0,0 +1,144 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductLoanPrepaymentForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrow-down"></i>
<h3>Part-prepayment</h3>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="partPrepaymentAllowed">Part-prepayment allowed</mat-checkbox>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Part-payment (₹)</mat-label>
<input matInput type="number" formControlName="minPartPayment" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allowed Only On</mat-label>
<mat-select formControlName="allowedOn">
@for (opt of allowedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Part-payments per Year</mat-label>
<mat-select formControlName="partPaymentsPerYear">
@for (opt of perYearOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<mat-radio-group formControlName="partPrepaymentMode" class="adv-type-options adv-type-options--3">
<mat-radio-button value="reduce_tenure" class="adv-type-option">
<span class="adv-type-option__label">Reduce tenure</span>
<span class="adv-type-option__hint">EMI stays the same</span>
</mat-radio-button>
<mat-radio-button value="reduce_emi" class="adv-type-option">
<span class="adv-type-option__label">Reduce EMI</span>
<span class="adv-type-option__hint">Tenure stays the same</span>
</mat-radio-button>
<mat-radio-button value="borrower_chooses" class="adv-type-option">
<span class="adv-type-option__label">Borrower chooses</span>
<span class="adv-type-option__hint">Picked at payment time</span>
</mat-radio-button>
</mat-radio-group>
<div style="height: 20px"></div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-lock"></i>
<h3>Pre-closure (Foreclosure)</h3>
<span class="adv-section__hint">Charge on outstanding principal</span>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · floating rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFloatingIndividual" min="0" max="100" step="0.01" />
<mat-hint>Nil as per RBI</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · fixed rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFixedIndividual" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Non-individual / business (%)</mat-label>
<input matInput type="number" formControlName="foreclosureNonIndividual" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Takeover by another bank (%)</mat-label>
<input matInput type="number" formControlName="foreclosureTakeover" min="0" max="100" step="0.01" />
</mat-form-field>
</div>
<div class="adv-grid adv-grid--3" style="padding-top: 0">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Lock-in Period (months)</mat-label>
<input matInput type="number" formControlName="lockInMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Pre-closure</mat-label>
<mat-select formControlName="interestOnPreclosure">
@for (opt of interestOnPreclosureOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Foreclosure Quote Valid (days)</mat-label>
<input matInput type="number" formControlName="foreclosureQuoteValidDays" min="1" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-flag-checkered"></i>
<h3>Closure &amp; Restructuring</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Auto-close on Zero Balance</mat-label>
<mat-select formControlName="autoCloseOnZeroBalance">
@for (opt of autoCloseOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NOC / Closure Letter</mat-label>
<mat-select formControlName="nocLetter">
@for (opt of nocOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Restructuring / Re-schedule</mat-label>
<mat-select formControlName="restructuring">
@for (opt of restructuringOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="releaseCollateralOnClosure">Release collateral and lien on closure</mat-checkbox>
<mat-checkbox formControlName="topUpAllowed">Top-up loan on the same account</mat-checkbox>
<mat-checkbox formControlName="balanceTransferOutAllowed">Allow balance transfer (takeover) out</mat-checkbox>
</div>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">Unlike an Advance, there is no renewal. A loan ends at maturity or on pre-closure.</div>
</div>
</div>
</form>
@@ -0,0 +1,117 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Prepayment & Closure step for a Loans A/C (EMI) product (step 8). A loan has no renewal; it ends at
* maturity or on pre-closure. UI-only for now: not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-loan-prepayment-step',
templateUrl: './loan-product-loan-prepayment-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanPrepaymentStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanPrepaymentForm: UntypedFormGroup;
readonly allowedOnOptions = [
{ id: 'emi_date', label: 'EMI due date' },
{ id: 'any_day', label: 'Any day' }
];
readonly perYearOptions = [
{ id: 'unlimited', label: 'Unlimited' },
{ id: 'one', label: 'Once a year' },
{ id: 'two', label: 'Twice a year' }
];
readonly interestOnPreclosureOptions = [
{ id: 'till_date', label: 'Till date of payment' },
{ id: 'full_month', label: 'Full current month' }
];
readonly autoCloseOptions = [
'YES',
'NO'
];
readonly nocOptions = [
{ id: 'auto', label: 'Auto-generate' },
{ id: 'manual', label: 'On request' }
];
readonly restructuringOptions = [
{ id: 'approval', label: 'Allowed with approval' },
{ id: 'no', label: 'Not allowed' }
];
constructor() {
const percent = [
Validators.min(0),
Validators.max(100)
];
this.loanProductLoanPrepaymentForm = this.formBuilder.group({
partPrepaymentAllowed: [true],
minPartPayment: [
'',
Validators.min(0)
],
allowedOn: ['emi_date'],
partPaymentsPerYear: ['unlimited'],
partPrepaymentMode: ['reduce_tenure'],
foreclosureFloatingIndividual: [
0,
percent
],
foreclosureFixedIndividual: [
2,
percent
],
foreclosureNonIndividual: [
3,
percent
],
foreclosureTakeover: [
2,
percent
],
lockInMonths: [
0,
Validators.min(0)
],
interestOnPreclosure: ['till_date'],
foreclosureQuoteValidDays: [
7,
Validators.min(1)
],
autoCloseOnZeroBalance: ['YES'],
nocLetter: ['auto'],
restructuring: ['approval'],
releaseCollateralOnClosure: [true],
topUpAllowed: [false],
balanceTransferOutAllowed: [true]
});
}
get loanProductLoanPrepayment() {
return this.loanProductLoanPrepaymentForm.getRawValue();
}
}
@@ -0,0 +1,155 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductLoanRepaymentForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-wave-square"></i>
<h3>Repayment Type</h3>
</div>
<mat-radio-group formControlName="repaymentType" class="adv-type-options adv-type-options--3">
@for (opt of repaymentTypeOptions; track opt.id) {
<mat-radio-button [value]="opt.id" class="adv-type-option">
<span class="adv-type-option__label">{{ opt.label }}</span>
<span class="adv-type-option__hint">{{ opt.hint }}</span>
</mat-radio-button>
}
</mat-radio-group>
<div style="padding: 16px 24px 20px">
<div class="adv-info">
This is the main difference from an Advance. Repayment follows a fixed schedule, not a running balance.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-calendar-alt"></i>
<h3>Schedule</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repaid Every</mat-label>
<input matInput type="number" formControlName="repaidEvery" min="1" required />
<mat-error>Repaid Every is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Frequency</mat-label>
<mat-select formControlName="frequency">
@for (opt of frequencyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Days to First Repayment</mat-label>
<input matInput type="number" formControlName="minDaysToFirstRepayment" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>EMI Due Day</mat-label>
<mat-select formControlName="emiDueDay">
@for (opt of emiDueDayOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>If Due Date is a Holiday</mat-label>
<mat-select formControlName="holidayRule">
@for (opt of holidayRuleOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Moratorium / Grace (months)</mat-label>
<input matInput type="number" formControlName="moratoriumMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest during Moratorium</mat-label>
<mat-select formControlName="moratoriumInterest">
@for (opt of moratoriumInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="variableInstallments">Variable installments allowed</mat-checkbox>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-money-check"></i>
<h3>Repayment Mode</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repayment Mode</mat-label>
<mat-select formControlName="repaymentMode">
@for (opt of repaymentModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PDC Collection</mat-label>
<mat-select formControlName="pdcCollection">
@for (opt of pdcOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is
therefore not part of a Loan A/C.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-list-ol"></i>
<h3>Appropriation Order</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Appropriation Order</mat-label>
<mat-select formControlName="appropriationOrder">
@for (opt of appropriationOrderOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Advance EMI Payment</mat-label>
<mat-select formControlName="advanceEmi">
@for (opt of advanceEmiOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</form>
@@ -0,0 +1,129 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Repayment & EMI step for a Loans A/C (EMI) product (step 5) — the step an Advance does not have.
* Repayment type and frequency feed Fineract's core product fields; the repayment mode and the
* rest of the schedule options are UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-repayment-step',
templateUrl: './loan-product-loan-repayment-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanRepaymentStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanRepaymentForm: UntypedFormGroup;
readonly repaymentTypeOptions = [
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' },
{ id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' },
{ id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' }
];
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
readonly frequencyOptions = [
{ id: 'months', label: 'Months', fineractId: 2 },
{ id: 'weeks', label: 'Weeks', fineractId: 1 },
{ id: 'days', label: 'Days', fineractId: 0 }
];
readonly emiDueDayOptions = [
{ id: 'disbursement_day', label: 'Same day as disbursement' },
{ id: 'fixed_day', label: 'Fixed day of month (5th)' }
];
readonly holidayRuleOptions = [
{ id: 'next', label: 'Next working day' },
{ id: 'previous', label: 'Previous working day' },
{ id: 'same', label: 'Same day' }
];
readonly moratoriumInterestOptions = [
{ id: 'pre_emi', label: 'Pay monthly (Pre-EMI)' },
{ id: 'capitalise', label: 'Capitalise into loan' }
];
readonly repaymentModeOptions = [
{ id: 'nach', label: 'Auto-debit (NACH / ECS)' },
{ id: 'pdc', label: 'Post-dated cheques (PDC)' },
{ id: 'cash', label: 'Cash / transfer at branch' }
];
readonly linkedAccountOptions = [
{ id: 'savings_current', label: 'Savings / Current' },
{ id: 'savings', label: 'Savings only' }
];
readonly pdcOptions = [
{ id: 'mandatory', label: 'Mandatory — 3 cheques' },
{ id: 'optional', label: 'Optional' },
{ id: 'none', label: 'Not collected' }
];
readonly appropriationOrderOptions = [
{ id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' },
{ id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' }
];
readonly advanceEmiOptions = [
{ id: 'adjust_next', label: 'Allowed — adjust next EMI' },
{ id: 'not_allowed', label: 'Not allowed' }
];
constructor() {
this.loanProductLoanRepaymentForm = this.formBuilder.group({
repaymentType: ['emi'],
repaidEvery: [
1,
[
Validators.required,
Validators.min(1)
]
],
frequency: ['months'],
minDaysToFirstRepayment: [
'',
Validators.min(0)
],
emiDueDay: ['fixed_day'],
holidayRule: ['next'],
moratoriumMonths: [
0,
Validators.min(0)
],
moratoriumInterest: ['pre_emi'],
variableInstallments: [false],
repaymentMode: ['nach'],
linkedAccountType: ['savings_current'],
pdcCollection: ['mandatory'],
mandateRequired: [true],
appropriationOrder: ['charges_penal_interest_principal'],
advanceEmi: ['adjust_next']
});
}
get loanProductLoanRepayment() {
return this.loanProductLoanRepaymentForm.getRawValue();
}
}
@@ -0,0 +1,130 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductLoanSecurityForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-shield-alt"></i>
<h3>Security</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Security Type</mat-label>
<input matInput formControlName="securityType" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Security Mandatory</mat-label>
<mat-select formControlName="securityMandatory">
@for (opt of securityMandatoryOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
<mat-error>Margin must be between <strong>0 and 100</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum LTV (%)</mat-label>
<input matInput [value]="ltv" readonly />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table"></i>
<h3>Collateral Rules by Security Type</h3>
<span class="adv-section__hint">Which security this product may accept</span>
</div>
<table class="adv-table">
<thead>
<tr>
<th>Security</th>
<th>Allowed</th>
<th>Max LTV %</th>
<th>Valuation</th>
<th>Insurance</th>
<th>Charge / lien</th>
</tr>
</thead>
<tbody>
@for (rule of collateralRules; track rule.security) {
<tr>
<td>{{ rule.security }}</td>
<td [class.adv-table__yes]="rule.allowed" [class.adv-table__no]="!rule.allowed">
{{ rule.allowed ? 'Yes' : 'No' }}
</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.ltv }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.valuation }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.insurance }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.charge }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-plus"></i>
<h3>Guarantor &amp; Co-applicant</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Guarantors Required (min)</mat-label>
<input matInput type="number" formControlName="guarantorsRequired" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Co-applicant</mat-label>
<mat-select formControlName="coApplicant">
@for (opt of coApplicantOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-check-circle"></i>
<h3>Conditions</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="legalOpinionRequired">
Legal opinion / title search mandatory before sanction
</mat-checkbox>
<mat-checkbox formControlName="propertyInsuranceRequired">
Property insurance mandatory for the whole tenure
</mat-checkbox>
<mat-checkbox formControlName="registerCharge">Register charge (CERSAI) before disbursement</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">Release security automatically on closure</mat-checkbox>
<mat-checkbox formControlName="allowMultipleSecurities">Allow multiple securities for one loan</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">Allow third-party deposit as security</mat-checkbox>
</div>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Unlike an Advance, the security does not decide a running limit. It only decides eligibility at sanction.
Disbursement happens once, up to the sanctioned amount.
</div>
</div>
</div>
</form>

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