-
+
+
+
+
+
@for (o of availableAccounts(role, i); track o.id) {
{{ o.glCode }} · {{ o.name }}
}
diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts
index 4edfb90f0..a9bed2898 100644
--- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts
+++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts
@@ -7,8 +7,9 @@
*/
import { Component, OnInit, inject } from '@angular/core';
-import { FormsModule } from '@angular/forms';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs';
+import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
@@ -20,6 +21,15 @@ export interface ReserveMapping {
glAccountId: number;
weightPercentage: number;
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;
}
/**
@@ -43,7 +53,9 @@ export interface ReserveMapping {
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
- FormsModule
+ FormsModule,
+ ReactiveFormsModule,
+ NgxMatSelectSearchModule
]
})
export class RbiReserveConfigComponent implements OnInit {
@@ -80,9 +92,33 @@ export class RbiReserveConfigComponent implements OnInit {
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
- /** Re-slices what is on screen. No round trip - the full set is already held. */
+ /** Hands each mapping row an identity the template can track it by. See ReserveMapping.rowId. */
+ 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 {
this.glRoles = this.rolesForScreen();
+ for (const role of this.glRoles) {
+ if (!this.selection[role.configKey]) {
+ this.reloadSelection(role);
+ }
+ }
this.rebuildGroups();
}
@@ -230,11 +266,7 @@ export class RbiReserveConfigComponent implements OnInit {
for (const role of this.glRoles) {
// 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.
- 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.reloadSelection(role);
}
this.rebuildGroups();
}
@@ -288,9 +320,16 @@ 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 {
- return !(this.selection[role.configKey] || []).length;
+ return !(role.accounts || []).length;
}
get unmappedCount(): number {
@@ -327,11 +366,29 @@ export class RbiReserveConfigComponent implements OnInit {
// a bank look compliant when it is not.
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 {
- this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
+ const rows = this.selection[role.configKey] || [];
+ const removed = rows[index];
+ this.selection[role.configKey] = rows.filter((_, i) => i !== index);
+ if (removed?.glAccountId) {
+ this.saveRole(role);
+ }
}
/**
@@ -343,7 +400,23 @@ export class RbiReserveConfigComponent implements OnInit {
const rows = this.selection[role.configKey] || [];
const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`));
const current = rows[index];
- return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
+ const free = 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. */
@@ -379,6 +452,16 @@ export class RbiReserveConfigComponent implements OnInit {
.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 {
this.savingKey = role.configKey;
this.errorMsg = '';
@@ -398,6 +481,11 @@ export class RbiReserveConfigComponent implements OnInit {
catchError((err: any) => {
this.savingKey = null;
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);
})
)
diff --git a/src/app/pipes/accounts-filter.pipe.ts b/src/app/pipes/accounts-filter.pipe.ts
index 3b7363383..3d7ec3213 100644
--- a/src/app/pipes/accounts-filter.pipe.ts
+++ b/src/app/pipes/accounts-filter.pipe.ts
@@ -62,7 +62,7 @@ export class AccountsFilterPipe implements PipeTransform {
account.status.code === 'savingsAccountStatusType.rejected'
);
});
- } else {
+ } else if (status !== 'all') {
accounts = accounts.filter((account: any) => {
return (
account.status.code !== 'savingsAccountStatusType.withdrawn.by.applicant' &&
diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html
new file mode 100644
index 000000000..3e0eff78f
--- /dev/null
+++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
DDS Product Creation
+
{{ isEdit ? 'Update this Daily Deposit product.' : 'Register a Daily Deposit product.' }}
+
+
+
+
+
+
diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss
new file mode 100644
index 000000000..a6a629710
--- /dev/null
+++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss
@@ -0,0 +1,112 @@
+/**
+ * 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/.
+ */
+
+.dat-root {
+ padding: 20px 24px 40px;
+}
+
+.dat-card {
+ background: #fff;
+ border: 1px solid rgb(0 0 0 / 8%);
+ border-radius: 10px;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
+ overflow: hidden;
+ max-width: 880px;
+ margin: 0 auto;
+}
+
+.dat-head {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 20px 24px;
+ border-bottom: 1px solid rgb(0 0 0 / 7%);
+
+ h2 {
+ margin: 0;
+ font-size: 1.2rem;
+ font-weight: 600;
+ }
+
+ p {
+ margin: 2px 0 0;
+ font-size: 0.83rem;
+ color: rgb(0 0 0 / 55%);
+ }
+}
+
+.dat-head__ic {
+ width: 46px;
+ height: 46px;
+ border-radius: 12px;
+ display: grid;
+ place-items: center;
+ font-size: 1.05rem;
+ color: #fff;
+ background: linear-gradient(135deg, #0f766e, #134e4a);
+ flex: none;
+}
+
+.dat-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(220px, 1fr));
+ gap: 8px 20px;
+ padding: 26px 24px 8px;
+}
+
+.dat-field {
+ width: 100%;
+}
+
+.dat-field--wide {
+ grid-column: 1 / -1;
+}
+
+/*
+ * Divides the scheme's own settings from the terms it charges for closing early. A rule rather than a
+ * second card, because it is the same record being filled in - the break is to stop three money
+ * figures reading as more of the posting settings above them.
+ */
+.dat-section {
+ margin: 20px 0 0;
+ padding: 18px 24px 0;
+ border-top: 1px solid rgb(0 0 0 / 7%);
+ font-size: 0.82rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #0f766e;
+}
+
+.dat-section-note {
+ margin: 6px 0 0;
+ padding: 0 24px;
+ max-width: 68ch; /* stylelint-disable-line unit-allowed-list */
+ font-size: 0.8rem;
+ line-height: 1.5;
+ color: rgb(0 0 0 / 60%);
+}
+
+/* The note already carries the spacing, so the grid under it does not add its own again. */
+.dat-section-note + .dat-grid {
+ padding-top: 14px;
+}
+
+.dat-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 16px 24px;
+ border-top: 1px solid rgb(0 0 0 / 7%);
+}
+
+@media (width <= 720px) {
+ .dat-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts
new file mode 100644
index 000000000..0cdbcf188
--- /dev/null
+++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts
@@ -0,0 +1,234 @@
+/**
+ * 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/.
+ */
+
+/** Angular Imports */
+import { Component, OnInit, inject } from '@angular/core';
+import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+/** Custom Services */
+import { ProductsService } from 'app/products/products.service';
+import { AlertService } from 'app/core/alert/alert.service';
+import { OrganizationService } from 'app/organization/organization.service';
+import { SettingsService } from 'app/settings/settings.service';
+import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
+
+/**
+ * Account Type Creation — the legacy DDS screen this replaces, field for field: group, code,
+ * description, and whether interest posts automatically and how often. Nothing else: DDS never used
+ * the deposit-product engine, so there are no terms, charges or accounting tabs behind this.
+ *
+ * The same form handles create and edit; an `accountTypeId` in the route switches it to edit.
+ */
+@Component({
+ selector: 'mifosx-create-dds-account-type',
+ templateUrl: './create-dds-account-type.component.html',
+ styleUrls: ['./create-dds-account-type.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FaIconComponent
+ ]
+})
+export class CreateDdsAccountTypeComponent implements OnInit {
+ private formBuilder = inject(UntypedFormBuilder);
+ private router = inject(Router);
+ private route = inject(ActivatedRoute);
+ private productsService = inject(ProductsService);
+ private alertService = inject(AlertService);
+ private organizationService = inject(OrganizationService);
+ private settingsService = inject(SettingsService);
+
+ accountTypeGroups = ['DDS'];
+
+ autoInterestPostWhenOptions = [
+ 'Monthly',
+ 'Quarterly',
+ 'Half Yearly',
+ 'Yearly'
+ ];
+
+ /** Set when the route carries an id — the form is then editing rather than creating. */
+ accountTypeId: number | null = null;
+
+ accountTypeForm: UntypedFormGroup = this.formBuilder.group({
+ accountTypeGroup: [
+ 'DDS',
+ Validators.required
+ ],
+ accountTypeCode: [
+ '',
+ Validators.required
+ ],
+ accountTypeDescription: [
+ '',
+ Validators.required
+ ],
+ autoInterestPost: [
+ true,
+ Validators.required
+ ],
+ autoInterestPostWhen: [
+ 'Monthly',
+ Validators.required
+ ],
+ autoInterestPostDay: [''],
+ /*
+ * Defaults to No, so a scheme created without a thought about it keeps behaving the way every
+ * scheme behaves today - the clerk pays out matured accounts by hand. Turning it on is a
+ * decision somebody makes, not one they inherit.
+ */
+ maturityAutoClose: [
+ false,
+ Validators.required
+ ],
+ // Mandatory: every account opened on this scheme takes this rate, rather than one typed per customer.
+ interestRate: [
+ '',
+ [
+ Validators.required,
+ Validators.min(0),
+ Validators.max(100)
+ ]
+ ],
+ /*
+ * What this scheme charges when an account is closed before its term is up. Set here rather than
+ * asked of the clerk at closing time: the terms are the bank's, and the same for every account on
+ * the scheme. Defaults are the bank's stated terms - 12 months, 1% and 1%.
+ */
+ prematureLockinMonths: [
+ 12,
+ [
+ Validators.required,
+ Validators.min(0),
+ Validators.max(600)
+ ]
+ ],
+ prematureInterestReduction: [
+ 1,
+ [
+ Validators.required,
+ Validators.min(0),
+ Validators.max(100)
+ ]
+ ],
+ prematureAgentCommissionPenalty: [
+ 1,
+ [
+ Validators.required,
+ Validators.min(0),
+ Validators.max(100)
+ ]
+ ]
+ });
+
+ get isEdit(): boolean {
+ return this.accountTypeId !== null;
+ }
+
+ /**
+ * "Auto Interest Post Day" only means anything while auto-posting is on, and the legacy screen
+ * hid it the same way. "When" stays visible either way, as it did there.
+ */
+ get autoPostOn(): boolean {
+ return !!this.accountTypeForm.get('autoInterestPost')?.value;
+ }
+
+ ngOnInit(): void {
+ // The posting day is required exactly while auto-posting is on, and is cleared when it is off.
+ const dayControl = this.accountTypeForm.get('autoInterestPostDay');
+ const applyDayRule = (autoPostOn: boolean) => {
+ if (autoPostOn) {
+ dayControl?.setValidators([Validators.required]);
+ } else {
+ dayControl?.clearValidators();
+ dayControl?.setValue('');
+ }
+ dayControl?.updateValueAndValidity();
+ };
+ applyDayRule(this.autoPostOn);
+ this.accountTypeForm.get('autoInterestPost')?.valueChanges.subscribe((value) => applyDayRule(!!value));
+
+ const routeId = this.route.snapshot.paramMap.get('accountTypeId');
+ if (!routeId) {
+ return;
+ }
+ this.accountTypeId = Number(routeId);
+ this.productsService.getDdsAccountType(this.accountTypeId).subscribe((accountType: any) => {
+ this.accountTypeForm.patchValue({
+ accountTypeGroup: accountType.accountTypeGroup,
+ accountTypeCode: accountType.accountTypeCode,
+ accountTypeDescription: accountType.accountTypeDescription,
+ autoInterestPost: !!accountType.autoInterestPost,
+ autoInterestPostWhen: accountType.autoInterestPostWhen || 'Monthly',
+ autoInterestPostDay: accountType.autoInterestPostDay ?? '',
+ maturityAutoClose: !!accountType.maturityAutoClose,
+ interestRate: accountType.interestRate ?? '',
+ // ?? rather than ||, because zero is a real answer here - a scheme with no lock-in, or one
+ // that charges nothing - and must not be replaced by the default.
+ prematureLockinMonths: accountType.prematureLockinMonths ?? 12,
+ prematureInterestReduction: accountType.prematureInterestReduction ?? 1,
+ prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1
+ });
+ });
+ }
+
+ cancel(): void {
+ this.backToList();
+ }
+
+ submit(): void {
+ if (this.accountTypeForm.invalid) {
+ return;
+ }
+ const raw = this.accountTypeForm.getRawValue();
+ const payload = {
+ ...raw,
+ // The posting day only applies while auto-posting is on; send nothing for it otherwise.
+ autoInterestPostDay:
+ raw.autoInterestPost && raw.autoInterestPostDay ? Number(raw.autoInterestPostDay) : undefined,
+ // Sent as numbers, not the strings a text input yields - the backend reads them as decimals.
+ interestRate: Number(raw.interestRate),
+ prematureLockinMonths: Number(raw.prematureLockinMonths),
+ prematureInterestReduction: Number(raw.prematureInterestReduction),
+ prematureAgentCommissionPenalty: Number(raw.prematureAgentCommissionPenalty),
+ // Numeric parameters have to carry the locale they were entered in.
+ locale: this.settingsService.language.code
+ };
+
+ const done = (response: any) => {
+ if (heldForApproval(response)) {
+ announceHeldForApproval(
+ response,
+ `DDS product ${payload.accountTypeCode} ${this.isEdit ? 'change' : ''}`.trim(),
+ this.organizationService,
+ this.alertService
+ );
+ } else {
+ this.alertService.alert({
+ type: 'Success',
+ message: this.isEdit
+ ? `Account type ${payload.accountTypeCode} updated`
+ : `Account type ${payload.accountTypeCode} created`
+ });
+ }
+ this.backToList();
+ };
+
+ if (this.accountTypeId !== null) {
+ this.productsService.updateDdsAccountType(this.accountTypeId, payload).subscribe(done);
+ } else {
+ this.productsService.createDdsAccountType(payload).subscribe(done);
+ }
+ }
+
+ private backToList(): void {
+ this.router.navigate(['/products/dds-account-types']);
+ }
+}
diff --git a/src/app/products/dds-account-types/dds-account-types.component.html b/src/app/products/dds-account-types/dds-account-types.component.html
new file mode 100644
index 000000000..cea8c4202
--- /dev/null
+++ b/src/app/products/dds-account-types/dds-account-types.component.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
{{ totalCount | number }}
+
Total DDS Products
+
+
+
+
+
+
{{ filteredCount | number }}
+
Showing after filter
+
+
+
+
+
+
+
+ @if (totalCount === 0) {
+
+
+
No account types yet
+
Create one so Daily Deposit accounts can be opened against it.
+
+
+ Create DDS Product
+
+
+ } @else {
+
+
+
+ Product
+
+
+
+
+
+
+ Description
+ {{ t.accountTypeDescription }}
+
+
+
+ Auto Interest Post
+
+ @if (t.autoInterestPost) {
+ {{ t.autoInterestPostWhen || 'Yes' }}
+ @if (t.autoInterestPostDay) {
+ day {{ t.autoInterestPostDay }}
+ }
+ } @else {
+ No
+ }
+
+
+
+
+ Created
+
+ {{ t.createdDate ? (t.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
+
+
+
+
+ Actions
+
+
+ Edit
+
+
+
+
+
+
+
+
+ @if (filteredCount === 0 && totalCount > 0) {
+
+
+ No account types match your filter.
+
+ }
+
+
+
+ }
+
+
diff --git a/src/app/products/dds-account-types/dds-account-types.component.scss b/src/app/products/dds-account-types/dds-account-types.component.scss
new file mode 100644
index 000000000..9d6517e68
--- /dev/null
+++ b/src/app/products/dds-account-types/dds-account-types.component.scss
@@ -0,0 +1,39 @@
+/**
+ * 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/.
+ */
+
+@use 'assets/styles/product-list' as pl;
+
+@include pl.product-list-styles;
+
+/* DDS-specific teal accent */
+:host {
+ --pl-accent: #0f766e;
+ --pl-accent-l: #ccfbf1;
+}
+
+/* A retired account type stays listed but reads as switched off. */
+.dat-row--inactive td:not(.num) {
+ opacity: 0.55;
+}
+
+.dat-pill {
+ display: inline-block;
+ padding: 3px 11px;
+ border-radius: 11px;
+ font-size: 0.72rem;
+ font-weight: 500;
+ line-height: 1.7;
+ background: #dcfce7;
+ color: #15803d;
+ white-space: nowrap;
+}
+
+.dat-pill--off {
+ background: rgb(0 0 0 / 7%);
+ color: rgb(0 0 0 / 52%);
+}
diff --git a/src/app/products/dds-account-types/dds-account-types.component.ts b/src/app/products/dds-account-types/dds-account-types.component.ts
new file mode 100644
index 000000000..9b3e13e57
--- /dev/null
+++ b/src/app/products/dds-account-types/dds-account-types.component.ts
@@ -0,0 +1,112 @@
+/**
+ * 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/.
+ */
+
+/** Angular Imports */
+import { Component, OnInit, ViewChild, inject } from '@angular/core';
+import { MatPaginator } from '@angular/material/paginator';
+import { MatSort, MatSortHeader } from '@angular/material/sort';
+import {
+ MatTable,
+ MatTableDataSource,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow
+} from '@angular/material/table';
+import { MatTooltip } from '@angular/material/tooltip';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+/** Custom Services */
+import { ProductsService } from 'app/products/products.service';
+
+/**
+ * DDS Account Types list.
+ *
+ * The scheme variants a Party account can be opened under - the legacy "Account Type Creation"
+ * record. Master data only, deliberately not a deposit product: DDS never used the deposit-product
+ * engine, and interest is accrued off each Party's own rate by the DDS batch job.
+ *
+ * Follows the same list -> create/edit shape as every other product list in this section, and
+ * shares their stylesheet.
+ */
+@Component({
+ selector: 'mifosx-dds-account-types',
+ templateUrl: './dds-account-types.component.html',
+ styleUrls: ['./dds-account-types.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FaIconComponent,
+ MatTable,
+ MatSort,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatSortHeader,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow,
+ MatPaginator,
+ MatTooltip
+ ]
+})
+export class DdsAccountTypesComponent implements OnInit {
+ private productsService = inject(ProductsService);
+
+ displayedColumns: string[] = [
+ 'accountTypeCode',
+ 'accountTypeDescription',
+ 'autoInterestPost',
+ 'createdDate',
+ 'actions'
+ ];
+
+ dataSource: MatTableDataSource = new MatTableDataSource([]);
+ accountTypes: any[] = [];
+ filteredCount = 0;
+
+ @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
+ @ViewChild(MatSort, { static: true }) sort: MatSort;
+
+ get totalCount(): number {
+ return this.accountTypes.length;
+ }
+
+ ngOnInit(): void {
+ this.loadAccountTypes();
+ }
+
+ private loadAccountTypes(): void {
+ this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => {
+ this.accountTypes = accountTypes || [];
+ this.dataSource = new MatTableDataSource(this.accountTypes);
+ this.dataSource.paginator = this.paginator;
+ this.dataSource.sort = this.sort;
+ this.filteredCount = this.accountTypes.length;
+ this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
+ });
+ }
+
+ applyFilter(filterValue: string): void {
+ this.dataSource.filter = filterValue.trim().toLowerCase();
+ }
+
+ /**
+ * Retires or restores an account type. Never a delete: Parties are opened against an account
+ * type, so removing it would orphan every account already using it. Deactivating takes it out of
+ * the pick lists for new accounts and leaves existing ones untouched.
+ */
+}
diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html
new file mode 100644
index 000000000..25a387ffa
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+
+
+
●
+
CURRENT ACTIVE
+
{{ active.name }}
+
· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}
+
You are viewing the Active register
+
View active →
+
+
+
+
+
+
+
+
Credit score slabs
+
+ Only the Adjustment % is editable · changes apply to new sanctions after the register is approved
+
+
+
+ Add slab
+
+
+
+
+
+
+
+
diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss
new file mode 100644
index 000000000..19f0c1489
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.scss
@@ -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, ");
+ 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;
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts
new file mode 100644
index 000000000..701b5667c
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/cibil-adjustment/cibil-adjustment.component.ts
@@ -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 = {
+ '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();
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html
new file mode 100644
index 000000000..35c9f7843
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.html
@@ -0,0 +1,199 @@
+
+
+
+
+
●
+
CURRENT ACTIVE
+
{{ active.name }}
+
· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}
+
You are viewing the Active register
+
View active →
+
+
+
+
+
+
Read-only — {{ register.status }}
+
{{ readOnlyReason() }}
+
+
+
+
+
+
+
+
Rate chart grid
+
+ {{ rows.length }} products × {{ slabs.length }} amount slabs
+ · click any cell to edit
+ · read-only view
+ 0"
+ >· {{ unmappedCount() }} without GL — map every product's GL before submitting
+
+
+
+ Add product
+
+
+ Add slab
+
+
+
+
+
Add product
+
+
+ PRODUCT NAME
+
+
+
+ GL ACCOUNT (required)
+
+
+
Add
+
Cancel
+
+
{{ addProductError }}
+
+
+
Add amount slab
+
+
+ UPPER LIMIT (₹) — leave blank for "above the last slab"
+
+
+
+ SLAB LABEL
+ 50,00,000'"
+ />
+
+
Add
+
Cancel
+
+
The new slab is added as the last column; fill in its rates for each product.
+
{{ addSlabError }}
+
+
+
+
+
+
CS
+
+
CIBIL / credit score adjustment
+
+ {{ register.cibilSlabs.length }} slabs · +2.00% (very poor) to base (above 700) · applied on top of chart rate
+
+
+
Edit →
+
+
+
+
diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss
new file mode 100644
index 000000000..25e958ccc
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts
new file mode 100644
index 000000000..1900d114c
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/edit-register/edit-register.component.ts
@@ -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;
+
+ /** 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';
+ }
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts
new file mode 100644
index 000000000..c58959fb2
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/gl-picker/gl-picker.component.ts
@@ -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: `
+
+
+
+
+
+ GL not mapped
+ {{ o.label }}
+
+
+ `,
+ 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();
+ /** Fired when the dropdown panel closes, whether or not a value was picked. */
+ @Output() closed = new EventEmitter();
+
+ @ViewChild(MatSelect) private select?: MatSelect;
+
+ filterCtrl = new UntypedFormControl('');
+ filtered: GlOption[] = [];
+
+ private readonly destroy$ = new Subject();
+
+ 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;
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html
new file mode 100644
index 000000000..cf9e44f33
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+
ACTIVE REGISTER
+
{{ active?.name ?? 'None' }}
+
Effective {{ activeWindow() }}
+
+
+
SLABS
+
10 amount slabs
+
₹10k → > ₹25L
+
+
+
CIBIL ADJUSTMENTS
+
{{ active?.cibilSlabs?.length ?? 0 }} slabs
+
+2.00% (poor) → base (above 700)
+
+
+
+
+
+
+
Loan Interest Rate Register — Version History
+
All versions, newest first · click a version to expand its rate chart
+
+
+
+
+
+
No rate register versions yet
+
+ 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.
+
+
+ Create first version
+
+
+
+
0">
+
+
+
+
+
{{ r.note }}
+
+ Created by {{ r.createdBy }} · Last modified {{ r.lastModified | date: 'dd MMM yyyy' }}
+
+
+
Rate chart ({{ r.rows.length }} products × {{ r.slabs.length }} slabs)
+
+
+
CIBIL / credit-score slabs
+
+
+
+ #
+ Score from
+ Score to
+ Risk tier
+ Type
+ Adjustment
+
+
+
+
+ {{ i + 1 }}
+ {{ slab.from }}
+ {{ slab.to }}
+ {{ slab.tier }}
+ {{ slab.type }}
+ {{ formatPct(slab.adjustmentPct) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss
new file mode 100644
index 000000000..13cad796d
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.scss
@@ -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;
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts
new file mode 100644
index 000000000..2fc9f859b
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/loan-interest-rate-register.component.ts
@@ -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' });
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/models/rate-register.model.ts b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts
new file mode 100644
index 000000000..8f25e359f
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/models/rate-register.model.ts
@@ -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';
+}
diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html
new file mode 100644
index 000000000..8f258ebdf
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.html
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
Draft
+
{{ draft.name }}
+
+
+
Rate cells changed
+
{{ changedCellCount() }}
+
+
+
CIBIL slabs changed
+
{{ changedCibilCount() }}
+
+
+
+
+
+
+
OLD · WILL BE DEACTIVATED
+
{{ active?.name ?? 'No active register' }}
+
+ Effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}
+
+
+
→
+
+
NEW · WILL BE ACTIVATED
+
{{ draft.name }}
+
Effective {{ draft.effectiveFrom }} → {{ draft.effectiveTo || '—' }}
+
+
+
+
+
+
+
diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss
new file mode 100644
index 000000000..70d019be0
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts
new file mode 100644
index 000000000..71e7c00b5
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/preview-register/preview-register.component.ts
@@ -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,
+ {
+ 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 } });
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff.util.ts b/src/app/products/loan-interest-rate-register/rate-register-diff.util.ts
new file mode 100644
index 000000000..6a8ac8920
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/rate-register-diff.util.ts
@@ -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;
+}
diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html
new file mode 100644
index 000000000..7e421085b
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
Rate chart — inline diff
+
Changed cells highlighted · unchanged cells show single value
+
+
0" [(ngModel)]="showOnlyChanged" class="rd__toggle">
+ Show only changed rows
+
+
+
+
+
+
No changes detected
+
The proposed version is identical to the current Active register.
+
+
+
+
+
+
+ 0">
+
+
+
CIBIL slabs — inline diff
+
Changed fields shown as OLD → NEW · unchanged fields render once
+
+
+
+
+
+ #
+ Score from
+ Score to
+ Tier
+ Adjustment
+
+
+
+
+ {{ i + 1 }}
+
+
+ {{ baselineCibil[i]?.from ?? '—' }}
+ →
+ {{ slab.from }}
+
+ {{ slab.from }}
+
+
+
+ {{ baselineCibil[i]?.to ?? '—' }}
+ →
+ {{ slab.to }}
+
+ {{ slab.to }}
+
+
+
+ {{ baselineCibil[i]?.tier ?? '—' }}
+ →
+ {{ slab.tier }}
+
+ {{ slab.tier }}
+
+
+
+ {{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }}
+ →
+ {{ formatPct(slab.adjustmentPct) }}
+
+ {{ formatPct(slab.adjustmentPct) }}
+
+
+
+
+
diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss
new file mode 100644
index 000000000..9cb68adaf
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts
new file mode 100644
index 000000000..9df948b0f
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component.ts
@@ -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)}%`;
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/rate-register.service.ts b/src/app/products/loan-interest-rate-register/rate-register.service.ts
new file mode 100644
index 000000000..16dfa332c
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/rate-register.service.ts
@@ -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();
+ 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 {
+ 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();
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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): RateRegister {
+ // Rebuild the slab columns from unique slabOrders.
+ const slabByOrder = new Map();
+ 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();
+ 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 {
+ 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}`;
+ }
+}
diff --git a/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts
new file mode 100644
index 000000000..e815d80f3
--- /dev/null
+++ b/src/app/products/loan-interest-rate-register/submit-details-dialog/submit-details-dialog.component.ts
@@ -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: `
+ Version details
+
+
+ Confirm these details before sending the rate register for approval.
+ It will replace {{ data.activeName }} (effective {{ data.activeFrom }}) once approved.
+
+
+ REGISTER NAME *
+
+
+
+ {{ error }}
+
+
+ Cancel
+ Submit for approval
+
+ `,
+ 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);
+ readonly data = inject(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 });
+ }
+}
diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html
index 260e066d8..d900c8414 100644
--- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html
+++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html
@@ -42,19 +42,168 @@
-
-
-
- {{ 'labels.inputs.SETTINGS' | translate }}
-
-
-
-
-
+ @if (isAdvanceProduct) {
+
+
+
+ SECURITY & LIEN
+
+
+
+
+
+
+
+
+
+ LIMIT & TENURE
+
+
+
+
+
+
+
+
+
+ INTEREST
+
+
+
+
+
+
+
+
+
+ PENAL & NPA
+
+
+
+
+
+
+
+
+
+ RENEWAL & CLOSURE
+
+
+
+
+
+
+ @if (isOverdraftFacility) {
+
+
+
+ CHEQUE BOOK
+
+
+
+
+
+ }
+ }
+
+ @if (isLoanAccount) {
+
+
+
+ SECURITY & COLLATERAL
+
+
+
+
+
+
+
+
+
+ AMOUNT & TENURE
+
+
+
+
+
+
+
+
+
+ REPAYMENT & EMI
+
+
+
+
+
+
+
+
+
+ INTEREST
+
+
+
+
+
+
+
+
+
+ PENAL & NPA
+
+
+
+
+
+
+
+
+
+ PREPAYMENT & CLOSURE
+
+
+
+
+
+ }
+
+
@@ -109,11 +263,12 @@
+
diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts
index 93785c36a..005e57c45 100644
--- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts
+++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts
@@ -13,11 +13,22 @@ import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */
import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component';
import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component';
+import { LoanProductSecurityLienStepComponent } from '../loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component';
+import { LoanProductAdvanceLimitTenureStepComponent } from '../loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component';
+import { LoanProductAdvanceInterestStepComponent } from '../loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component';
+import { LoanProductPenalNpaStepComponent } from '../loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component';
+import { LoanProductRenewalClosureStepComponent } from '../loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component';
+import { LoanProductChequeBookStepComponent } from '../loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component';
import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component';
import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component';
import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component';
import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component';
import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component';
+import { LoanProductLoanSecurityStepComponent } from '../loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component';
+import { LoanProductLoanAmountTenureStepComponent } from '../loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component';
+import { LoanProductLoanRepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component';
+import { LoanProductLoanInterestStepComponent } from '../loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component';
+import { LoanProductLoanPrepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component';
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
@@ -34,7 +45,7 @@ import {
import { Accounting } from 'app/core/utils/accounting';
import { StringEnumOptionData } from '../../../shared/models/option-data.model';
import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component';
-import { UntypedFormGroup } from '@angular/forms';
+import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component';
import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
@@ -51,12 +62,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent,
LoanProductDetailsStepComponent,
LoanProductCurrencyStepComponent,
- LoanProductSettingsStepComponent,
- LoanProductInterestRefundStepComponent,
- LoanProductPaymentStrategyStepComponent,
- LoanProductTermsStepComponent,
+ LoanProductSecurityLienStepComponent,
+ LoanProductAdvanceLimitTenureStepComponent,
+ LoanProductAdvanceInterestStepComponent,
+ LoanProductPenalNpaStepComponent,
+ LoanProductRenewalClosureStepComponent,
+ LoanProductChequeBookStepComponent,
+ LoanProductLoanSecurityStepComponent,
+ LoanProductLoanAmountTenureStepComponent,
+ LoanProductLoanRepaymentStepComponent,
+ LoanProductLoanInterestStepComponent,
+ LoanProductLoanPrepaymentStepComponent,
+ // Old Loans A/C path — commented out for now (its tabs are commented out in the template):
+ // LoanProductSettingsStepComponent,
+ // LoanProductInterestRefundStepComponent,
+ // LoanProductPaymentStrategyStepComponent,
+ // LoanProductTermsStepComponent,
+ // LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductChargesStepComponent,
- LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductAccountingStepComponent,
LoanProductPreviewStepComponent
]
@@ -73,12 +96,39 @@ export class CreateLoanProductComponent implements OnInit {
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
@ViewChild(LoanProductCurrencyStepComponent, { static: true })
loanProductCurrencyStep: LoanProductCurrencyStepComponent;
+ // Conditional tabs (Advances A/C only): static false — resolved after the @if renders them.
+ @ViewChild(LoanProductSecurityLienStepComponent)
+ loanProductSecurityLienStep: LoanProductSecurityLienStepComponent;
+ @ViewChild(LoanProductAdvanceLimitTenureStepComponent)
+ loanProductAdvanceLimitTenureStep: LoanProductAdvanceLimitTenureStepComponent;
+ @ViewChild(LoanProductAdvanceInterestStepComponent)
+ loanProductAdvanceInterestStep: LoanProductAdvanceInterestStepComponent;
+ @ViewChild(LoanProductPenalNpaStepComponent) loanProductPenalNpaStep: LoanProductPenalNpaStepComponent;
+ @ViewChild(LoanProductRenewalClosureStepComponent)
+ loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent;
+ @ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent;
+
+ // Conditional tabs (Loans A/C only): resolved after the @if renders them.
+ @ViewChild(LoanProductLoanSecurityStepComponent)
+ loanProductLoanSecurityStep: LoanProductLoanSecurityStepComponent;
+ @ViewChild(LoanProductLoanAmountTenureStepComponent)
+ loanProductLoanAmountTenureStep: LoanProductLoanAmountTenureStepComponent;
+ @ViewChild(LoanProductLoanRepaymentStepComponent)
+ loanProductLoanRepaymentStep: LoanProductLoanRepaymentStepComponent;
+ @ViewChild(LoanProductLoanInterestStepComponent)
+ loanProductLoanInterestStep: LoanProductLoanInterestStepComponent;
+ @ViewChild(LoanProductLoanPrepaymentStepComponent)
+ loanProductLoanPrepaymentStep: LoanProductLoanPrepaymentStepComponent;
+
+ /** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */
+ readonly multiDisburseFallback = new UntypedFormControl(false);
@ViewChild(LoanProductInterestRefundStepComponent, { static: true })
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
- @ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent;
- @ViewChild(LoanProductSettingsStepComponent, { static: true })
+ // Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically.
+ @ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent;
+ @ViewChild(LoanProductSettingsStepComponent)
loanProductSettingsStep: LoanProductSettingsStepComponent;
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
@ViewChild(LoanProductAccountingStepComponent, { static: true })
@@ -143,7 +193,23 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductTermsForm() {
- return this.loanProductTermsStep.loanProductTermsForm;
+ // Absent on the Advances path (tab hidden), so guard for callers.
+ return this.loanProductTermsStep?.loanProductTermsForm;
+ }
+
+ /** True once the Advance Limit & Tenure step reports an Overdraft (running) facility. */
+ get isOverdraftFacility(): boolean {
+ return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft;
+ }
+
+ /** The GL account chosen on the Details step; the rate register row can be matched by it. */
+ get productGlAccount(): number | string | null {
+ return this.loanProductDetailsStep?.loanProductDetailsForm?.get('glAccount')?.value ?? null;
+ }
+
+ /** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */
+ get advanceSubType(): string {
+ return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? '';
}
advancePaymentStrategy(value: string) {
@@ -231,47 +297,103 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductSettingsForm() {
- return this.loanProductSettingsStep.loanProductSettingsForm;
+ // Absent on the Advances path (tab hidden), so guard for callers.
+ return this.loanProductSettingsStep?.loanProductSettingsForm;
}
get loanProductAccountingForm() {
return this.loanProductAccountingStep.loanProductAccountingForm;
}
+ /** The Details step's chosen product type; drives the Advances-only tabs. */
+ get isAdvanceProduct(): boolean {
+ return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
+ }
+
+ /** The Details step's chosen product type; drives the Loans A/C (EMI) tabs. */
+ get isLoanAccount(): boolean {
+ return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'loans';
+ }
+
get totalTabs(): number {
- let tabs = 6; // details, currency, settings, terms, charges, accounting
- if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
+ let tabs = 4; // details, currency, charges, accounting (always)
+ if (this.isAdvanceProduct) {
+ // security & lien, limit & tenure, interest, penal & npa, renewal & closure
+ tabs += 5;
+ if (this.isOverdraftFacility) tabs += 1; // cheque book
+ } else if (this.isLoanAccount) {
+ // security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
+ tabs += 6;
+ }
+ // Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
+ // else {
+ // tabs += 2; // settings, terms
+ // if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
+ // }
if (this.loanProductFormValid) tabs += 1; // preview
return tabs;
}
get loanProductFormValid() {
- if (this.isAdvancedPaymentStrategy) {
+ const base =
+ this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
+
+ // Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
+ if (this.isAdvanceProduct) {
return (
- this.loanProductDetailsForm.valid &&
- this.loanProductCurrencyForm.valid &&
- this.loanProductTermsForm.valid &&
- this.loanProductSettingsForm.valid &&
- this.loanIncomeCapitalizationForm.valid &&
- this.loanProductAccountingForm.valid
- );
- } else {
- return (
- this.loanProductDetailsForm.valid &&
- this.loanProductCurrencyForm.valid &&
- this.loanProductTermsForm.valid &&
- this.loanProductSettingsForm.valid &&
- this.loanProductAccountingForm.valid
+ base &&
+ !!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
+ !!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
+ !!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
+ !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
+ !!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
+ (!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
);
}
+
+ // Loans A/C (EMI): the curated Loans steps gate the form; Settings/Terms tabs are commented out.
+ if (this.isLoanAccount) {
+ return (
+ base &&
+ !!this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm?.valid &&
+ !!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
+ !!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
+ !!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
+ !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
+ !!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
+ );
+ }
+
+ // No product type chosen yet.
+ return false;
+
+ // Old Loans A/C path (Settings + Terms) — commented out for now:
+ // const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid;
+ // if (this.isAdvancedPaymentStrategy) {
+ // return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid;
+ // }
+ // return base && loansValid;
}
get loanProduct() {
+ // Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
+ // (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
+ if (this.isAdvanceProduct) {
+ return this.assembleAdvanceProduct();
+ }
+
+ // Loans A/C (EMI): builds its own payload from the Loans steps, mapped onto Fineract's core fields.
+ if (this.isLoanAccount) {
+ return this.assembleLoanProduct();
+ }
+ return {};
+
+ /* OLD LOANS A/C PATH (Settings/Terms composition) — commented out for now.
const loanProduct = {
...this.loanProductDetailsStep.loanProductDetails,
...this.loanProductCurrencyStep.loanProductCurrency,
- ...this.loanProductTermsStep.loanProductTerms,
- ...this.loanProductSettingsStep.loanProductSettings,
+ ...(this.loanProductTermsStep?.loanProductTerms ?? {}),
+ ...(this.loanProductSettingsStep?.loanProductSettings ?? {}),
...this.loanProductChargesStep.loanProductCharges,
...this.loanProductAccountingStep.loanProductAccounting
};
@@ -302,6 +424,340 @@ export class CreateLoanProductComponent implements OnInit {
}
}
return loanProduct;
+ */
+ }
+
+ /**
+ * Builds the create payload for a Loans A/C (EMI) product: the Loans steps mapped onto Fineract's core
+ * product fields (so the standard product saves). The Loans-only options that have no core field yet
+ * (eligibility, collateral rules, prepayment, repayment mode, floating reset, register link) stay UI-only
+ * and are not sent — an unknown key would be rejected by checkForUnsupportedParameters.
+ */
+ private assembleLoanProduct(): any {
+ const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
+
+ const d = this.loanProductDetailsStep.loanProductDetails;
+ const cur = this.loanProductCurrencyStep.loanProductCurrency;
+ const at = this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenure;
+ const rp = this.loanProductLoanRepaymentStep.loanProductLoanRepayment;
+ const intr = this.loanProductLoanInterestStep.loanProductLoanInterest;
+ const sec = this.loanProductLoanSecurityStep.loanProductLoanSecurity;
+ const pp = this.loanProductLoanPrepaymentStep.loanProductLoanPrepayment;
+ const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
+
+ const stepper = this.loanProductLoanRepaymentStep;
+ const interestStep = this.loanProductLoanInterestStep;
+ const frequency = stepper.frequencyOptions.find((f) => f.id === rp.frequency)?.fineractId ?? 2;
+ const daysInYear = interestStep.daysInYearOptions.find((o) => o.id === intr.daysInYear)?.fineractId ?? 1;
+ const daysInMonth = interestStep.daysInMonthOptions.find((o) => o.id === intr.daysInMonth)?.fineractId ?? 1;
+
+ const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
+ const minTenure = num(at.minTenure);
+ const minRate = num(intr.minRate);
+ const multiTranche = at.disbursementType === 'multiple';
+ const moratorium = num(rp.moratoriumMonths);
+
+ const payload: any = {
+ // identity + currency
+ name: d.name,
+ shortName: d.shortName,
+ description: d.description,
+ startDate: d.startDate,
+ closeDate: d.closeDate,
+ currencyCode: cur.currencyCode,
+ digitsAfterDecimal: cur.digitsAfterDecimal,
+ inMultiplesOf: cur.inMultiplesOf ?? 1,
+
+ // amount -> principal
+ minPrincipal: num(at.minAmount),
+ principal: num(at.defaultAmount) ?? num(at.minAmount),
+ maxPrincipal: num(at.maxAmount),
+
+ // tenure (months) -> number of repayments
+ minNumberOfRepayments: minTenure,
+ numberOfRepayments: num(at.defaultTenure) ?? minTenure,
+ maxNumberOfRepayments: num(at.maxTenure),
+ repaymentEvery: num(rp.repaidEvery) ?? 1,
+ repaymentFrequencyType: frequency,
+ graceOnPrincipalPayment: moratorium && moratorium > 0 ? moratorium : undefined,
+
+ // rate
+ minInterestRatePerPeriod: minRate,
+ interestRatePerPeriod: num(intr.defaultRate) ?? minRate,
+ maxInterestRatePerPeriod: num(intr.maxRate),
+ interestRateFrequencyType: 3, // per year
+
+ // amortization / calculation
+ amortizationType: rp.repaymentType === 'equal_principal' ? 0 : 1, // 0 equal principal, 1 equal installments
+ interestType: intr.calcMethod === 'flat' ? 1 : 0, // 0 declining balance, 1 flat
+ interestCalculationPeriodType: intr.calcPeriod === 'daily' ? 0 : 1, // 0 daily, 1 same as repayment period
+ transactionProcessingStrategyCode: defaultStrategy,
+ daysInYearType: daysInYear,
+ daysInMonthType: daysInMonth,
+ isInterestRecalculationEnabled: false,
+
+ // disbursement
+ multiDisburseLoan: multiTranche,
+ maxTrancheCount: multiTranche ? num(at.maxTranches) : undefined,
+
+ // NPA / delinquency map onto core product fields
+ overdueDaysForNPA: num(pn.npaDays),
+ graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
+ inArrearsTolerance: num(pn.inArrearsTolerance),
+ delinquencyBucketId: pn.delinquencyBucketId || undefined,
+
+ // accounting rule + GL mappings + charges (all standard, whitelisted)
+ ...this.loanProductAccountingStep.loanProductAccounting,
+ ...this.loanProductChargesStep.loanProductCharges,
+
+ // Details-step satellite params (same whitelist the Advances path uses)
+ productTypeGroup: d.productTypeGroup,
+ productTypeSubGroup: d.productTypeSubGroup,
+ glAccount: d.glAccount || undefined,
+ loanProductCategory: d.loanProductCategory,
+ prioritySector: d.prioritySector,
+ cibilAccountType: d.cibilAccountType,
+ kfsEnabled: d.kfsEnabled,
+ aprEnabled: d.aprEnabled,
+
+ // Loans A/C (EMI) fields kept on the product satellite (same whitelist as the Advances fields)
+ margin: num(sec.margin),
+ ltv: sec.ltv,
+ securityType: sec.securityType,
+ repaymentType: rp.repaymentType,
+ appropriationOrder: rp.appropriationOrder,
+ interestCalcMethod: intr.calcMethod,
+ spread: num(intr.spread),
+ lockInMonths: num(pp.lockInMonths),
+ interestOnPreclosure: pp.interestOnPreclosure,
+ penalApplicable: pn.penalApplicable,
+ penalRate: num(pn.penalRate),
+ penalAppliedOn: pn.penalAppliedOn,
+ penalGraceDays: num(pn.penalGraceDays),
+ npaTrigger: pn.npaTrigger,
+ interestAfterNpa: pn.interestAfterNpa,
+ npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
+ npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
+ securityMandatory: sec.securityMandatory,
+ guarantorsRequired: num(sec.guarantorsRequired),
+ coApplicant: sec.coApplicant,
+ thirdPartySecurity: sec.thirdPartySecurity,
+ legalOpinionRequired: sec.legalOpinionRequired,
+ propertyInsuranceRequired: sec.propertyInsuranceRequired,
+ registerCharge: sec.registerCharge,
+ releaseSecurityOnClosure: sec.autoReleaseOnClosure,
+ allowMultipleSecurities: sec.allowMultipleSecurities,
+ allowThirdPartyDeposit: sec.allowThirdPartyDeposit,
+ minAge: num(at.minAge),
+ maxAgeAtMaturity: num(at.maxAgeAtMaturity),
+ minCibil: num(at.minCibil),
+ minMonthlyIncome: num(at.minMonthlyIncome),
+ maxFoir: num(at.maxFoir),
+ maxLoansPerCustomer: num(at.maxLoansPerCustomer),
+ disbursementType: at.disbursementType,
+ disbursementMode: at.disbursementMode,
+ undisbursedInterest: at.undisbursedInterest,
+ preEmiInterest: at.preEmiInterest,
+ emiDueDay: rp.emiDueDay,
+ holidayRule: rp.holidayRule,
+ moratoriumMonths: num(rp.moratoriumMonths),
+ moratoriumInterest: rp.moratoriumInterest,
+ variableInstallments: rp.variableInstallments,
+ repaymentMode: rp.repaymentMode,
+ linkedAccountType: rp.linkedAccountType,
+ pdcCollection: rp.pdcCollection,
+ mandateRequired: rp.mandateRequired,
+ advanceEmi: rp.advanceEmi,
+ rateType: intr.interestType,
+ registerParticular: intr.registerParticular,
+ applyCibilAdjustment: intr.applyCibilAdjustment,
+ rateResolvedAt: intr.rateResolvedAt,
+ calcPeriod: intr.calcPeriod,
+ interestStartsFrom: intr.interestStartsFrom,
+ compounding: intr.compounding,
+ benchmark: intr.benchmark,
+ resetFrequency: intr.resetFrequency,
+ resetChange: intr.resetChange,
+ partPrepaymentAllowed: pp.partPrepaymentAllowed,
+ minPartPayment: num(pp.minPartPayment),
+ allowedOn: pp.allowedOn,
+ partPaymentsPerYear: pp.partPaymentsPerYear,
+ partPrepaymentMode: pp.partPrepaymentMode,
+ foreclosureFloatingIndividual: num(pp.foreclosureFloatingIndividual),
+ foreclosureFixedIndividual: num(pp.foreclosureFixedIndividual),
+ foreclosureNonIndividual: num(pp.foreclosureNonIndividual),
+ foreclosureTakeover: num(pp.foreclosureTakeover),
+ foreclosureQuoteValidDays: num(pp.foreclosureQuoteValidDays),
+ autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
+ nocLetter: pp.nocLetter,
+ restructuring: pp.restructuring,
+ releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
+ topUpAllowed: pp.topUpAllowed,
+ balanceTransferOutAllowed: pp.balanceTransferOutAllowed
+ };
+
+ // Drop undefined keys so we never send an unresolved value.
+ Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
+ return payload;
+ }
+
+ /**
+ * Builds the create payload for an Advances A/C product: the curated wizard steps mapped onto Fineract's
+ * mandatory core fields (so the standard product saves) plus the whitelisted satellite params (persisted by
+ * LoanProductAdvanceExtService). Only keys the backend accepts are emitted — an unknown key would be
+ * rejected by checkForUnsupportedParameters.
+ */
+ private assembleAdvanceProduct(): any {
+ const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
+
+ const d = this.loanProductDetailsStep.loanProductDetails;
+ const cur = this.loanProductCurrencyStep.loanProductCurrency;
+ const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
+ const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
+ const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
+ const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
+ const rc = this.loanProductRenewalClosureStep.loanProductRenewalClosure;
+ const cb = this.isOverdraftFacility ? this.loanProductChequeBookStep?.loanProductChequeBook : null;
+
+ const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
+ const minTenure = num(lt.minTenure);
+ const minRate = num(intr.minRate);
+
+ const payload: any = {
+ // identity + currency
+ name: d.name,
+ shortName: d.shortName,
+ description: d.description,
+ startDate: d.startDate,
+ closeDate: d.closeDate,
+ currencyCode: cur.currencyCode,
+ digitsAfterDecimal: cur.digitsAfterDecimal,
+ inMultiplesOf: 1,
+
+ // limit -> principal
+ minPrincipal: num(lt.minLimit),
+ principal: num(lt.defaultLimit) ?? num(lt.minLimit),
+ maxPrincipal: num(lt.maxLimit),
+
+ // tenure -> repayments (monthly)
+ minNumberOfRepayments: minTenure,
+ numberOfRepayments: num(lt.defaultTenure) ?? minTenure,
+ maxNumberOfRepayments: num(lt.maxTenure),
+ repaymentEvery: 1,
+ repaymentFrequencyType: 2, // months
+
+ // rate
+ minInterestRatePerPeriod: minRate,
+ // A deposit-linked row ("1% + FD") has no plain minimum or maximum, so the register step supplies the default.
+ interestRatePerPeriod: num(intr.defaultRate) ?? minRate,
+ maxInterestRatePerPeriod: num(intr.maxRate),
+ interestRateFrequencyType: 3, // per year
+
+ // Fineract mandatory engine defaults for an Advance (no EMI schedule shown to the officer)
+ amortizationType: 1, // equal installments
+ interestType: 0, // declining balance
+ interestCalculationPeriodType: 1, // same as repayment period
+ transactionProcessingStrategyCode: defaultStrategy,
+ daysInYearType: 1, // actual
+ daysInMonthType: 1, // actual
+ isInterestRecalculationEnabled: false,
+
+ // NPA / delinquency map onto core product fields
+ overdueDaysForNPA: num(pn.npaDays),
+ graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
+ inArrearsTolerance: num(pn.inArrearsTolerance),
+ delinquencyBucketId: pn.delinquencyBucketId || undefined,
+
+ // accounting rule + GL mappings + charges (all standard, whitelisted)
+ ...this.loanProductAccountingStep.loanProductAccounting,
+ ...this.loanProductChargesStep.loanProductCharges,
+
+ // ---- satellite params (must match LoanProductAdvanceExtService.ADVANCE_PARAMETERS) ----
+ productTypeGroup: d.productTypeGroup,
+ productTypeSubGroup: d.productTypeSubGroup,
+ glAccount: d.glAccount || undefined,
+ loanProductCategory: d.loanProductCategory,
+ prioritySector: d.prioritySector,
+ cibilAccountType: d.cibilAccountType,
+ kfsEnabled: d.kfsEnabled,
+ aprEnabled: d.aprEnabled,
+ facilityType: lt.facilityType,
+ securityType: sec.securityType,
+ eligibleAmountBasis: sec.eligibleAmountBasis,
+ margin: num(sec.margin),
+ ltv: sec.ltv,
+ dpMethod: 'security_value_x_ltv',
+ lienRequired: this.loanProductSecurityLienStep.isLienSubType,
+ lienMarkingStage: sec.lienMarkingStage,
+ lienCertificateRequired: sec.lienCertificateRequired,
+ lienCertificatePrefix: sec.lienCertificatePrefix,
+ printCertificateOnDisbursement: sec.printOnDisbursement,
+ interestBasis: intr.interestBasis,
+ registerParticular: intr.registerParticular || undefined,
+ spread: num(intr.spread),
+ interestChargedOn: intr.interestChargedOn,
+ interestPostingFrequency: intr.postingFrequency,
+ penalApplicable: pn.penalApplicable,
+ penalRate: num(pn.penalRate),
+ penalAppliedOn: pn.penalAppliedOn,
+ penalGraceDays: num(pn.penalGraceDays),
+ npaTrigger: pn.npaTrigger,
+ interestAfterNpa: pn.interestAfterNpa,
+ renewalAllowed: rc.renewalAllowed,
+ renewalPeriodMonths: num(rc.renewalPeriodMonths),
+ renewalGraceDays: num(rc.renewalGraceDays),
+ preclosureAllowed: rc.preclosureAllowed,
+ lockInMonths: num(rc.lockInMonths),
+ interestOnPreclosure: rc.interestOnPreclosure,
+ chequeBookEnabled: this.isOverdraftFacility,
+ chequeType: cb?.chequeType,
+ defaultChequeBookSize: num(cb?.defaultBookSize),
+ ppsThreshold: num(cb?.ppsThreshold),
+ ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
+
+ // ---- remaining wizard fields ----
+ // security & lien extras (deposit list joined; rule checkboxes)
+ eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
+ lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
+ lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
+ lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
+ lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
+ lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
+ lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
+ // limit & tenure extras
+ repaymentType: lt.repaymentType,
+ appropriationOrder: lt.appropriationOrder,
+ debitBalanceAllow: lt.debitBalanceAllow,
+ allowDebitAfterDueDate: lt.allowDebitTransAfterDueDate,
+ branchLevelAccountType: lt.branchLevelAccountType,
+ // interest extras
+ interestCalcMethod: intr.calcMethod,
+ interestOnCreditBalance: intr.interestOnCreditBalance,
+ interestPostingDay: intr.postingDay,
+ // NPA upgrade rules
+ npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
+ npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
+ // cheque book full config
+ chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
+ chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
+ chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
+ chequeFirstBookFree: cb?.firstBookFree,
+ chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
+ chequeNamePrinted: cb?.namePrinted,
+ chequePrintDescription: cb?.chequePrintDescription,
+ chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
+ chequeMaintainStockRegister: cb?.maintainStockRegister,
+ chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
+ chequeBlockNpa: cb?.blockNpa,
+ chequeBlockLimitExpired: cb?.blockLimitExpired,
+ chequeBlockOverDp: cb?.blockOverDp,
+ chequeBlockFrozen: cb?.blockFrozen,
+ chequeBlockLienReleased: cb?.blockLienReleased
+ };
+
+ // Drop undefined keys so we never send an unresolved value.
+ Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
+ return payload;
}
submit() {
diff --git a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss
new file mode 100644
index 000000000..062f2f8fe
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss
@@ -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;
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html
new file mode 100644
index 000000000..2c616c118
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.html
@@ -0,0 +1,98 @@
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts
new file mode 100644
index 000000000..4aa3d2dda
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component.ts
@@ -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();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html
new file mode 100644
index 000000000..ed9f6e540
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html
@@ -0,0 +1,155 @@
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts
new file mode 100644
index 000000000..ddb60f62b
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts
@@ -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 });
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html
new file mode 100644
index 000000000..cbac09492
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.html
@@ -0,0 +1,144 @@
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts
new file mode 100644
index 000000000..3eade52c6
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component.ts
@@ -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();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html
index 49df9f6d2..e35ab7664 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.html
@@ -7,178 +7,190 @@
-->
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss
index f6a3f62d5..a5eecb505 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.scss
@@ -61,6 +61,57 @@ $gray-900: #202124;
&--full {
grid-column: 1 / -1;
}
+
+ // Starts a new row, so Start / Close Date sit together instead of one of them beside Short Name.
+ &--row-start {
+ grid-column-start: 1;
+ }
+}
+
+// Product Type — first section
+.lpd-type-options {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ padding: 16px 24px 0;
+
+ @media (width <= 600px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.lpd-type-option {
+ border: 1px solid $border;
+ border-radius: 8px;
+ padding: 8px 12px;
+
+ &__label {
+ display: block;
+ font-weight: 600;
+ color: $gray-900;
+ }
+
+ &__hint {
+ display: block;
+ font-size: 12px;
+ color: #5f6368;
+ }
+}
+
+.lpd-type-pending {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 16px;
+ padding: 12px 24px;
+ border: 1px dashed $border;
+ border-radius: 10px;
+ font-size: 13px;
+ color: #5f6368;
+
+ i {
+ color: $accent;
+ }
}
.lpd-checkbox-row {
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts
index ded56834c..6eb65abd2 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts
@@ -15,7 +15,9 @@ import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
+import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types';
@Component({
selector: 'mifosx-loan-product-details-step',
@@ -24,7 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
- CdkTextareaAutosize
+ CdkTextareaAutosize,
+ MatRadioGroup,
+ MatRadioButton
]
})
export class LoanProductDetailsStepComponent implements OnInit {
@@ -39,6 +43,14 @@ export class LoanProductDetailsStepComponent implements OnInit {
minDate = new Date(2000, 0, 1);
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 {Dates} dateUtils Date Utils.
@@ -50,90 +62,133 @@ export class LoanProductDetailsStepComponent implements OnInit {
}
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({
name: this.loanProductsTemplate.name,
shortName: this.loanProductsTemplate.shortName,
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)
});
-
- 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 */
- readonly productTypeGroups = [
- { id: 'loans', label: 'Loan' },
- { id: 'advanced', label: 'Advanced' }
+ /** True when editing a saved product; see ngOnInit. */
+ isExistingProduct = false;
+
+ /** 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 = {
- loans: [
- { id: 'emi', label: 'EMI' },
- { 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)' }
- ]
- };
+ readonly prioritySectorOptions = [
+ { id: 'yes', label: 'Yes' },
+ { id: 'no', label: 'No' }
+ ];
- /** Default GL per product type group */
- private readonly glDefaultMap: Record = {
- loans: 'asset',
- advanced: 'asset'
- };
+ readonly cibilAccountTypeOptions = [
+ { id: 'loan_bank_deposits', label: 'Loan against Bank Deposits' },
+ { id: 'gold_loan', label: 'Gold Loan' },
+ { 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() {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return group ? (this.productTypeSubGroups[group] ?? []) : [];
}
- get glOptions(): string[] {
- return [
- 'asset',
- 'liability'
- ];
- }
-
+ /**
+ * Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are
+ * generated from. Separate from the Accounting tab mappings, which drive the journal entries.
+ */
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 {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
}
- readonly yesNoOptions = [
- { value: true, label: 'YES' },
- { value: false, label: 'NO' }
- ];
+ /**
+ * A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything
+ * 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() {
this.loanProductDetailsForm = this.formBuilder.group({
@@ -150,21 +205,26 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: [''],
productTypeGroup: [''],
productTypeSubGroup: [''],
- glClassification: ['asset'],
glAccount: [''],
- debitIntCalculation: [false],
- separatePenalInterest: [false],
- creditIntCalculation: [false],
- subAccountTypeBalance: [false],
- finalIntRounding: [false],
- roundingUpTo: [null]
+ // Regulatory Classification (UI-only; stripped in buildPayload)
+ loanProductCategory: ['advances_deposits'],
+ prioritySector: ['no'],
+ cibilAccountType: ['loan_bank_deposits'],
+ kfsEnabled: ['YES'],
+ aprEnabled: ['YES']
});
- // Auto-set GL based on product type group
- this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => {
- const defaultVal = this.glDefaultMap[group] ?? 'asset';
- this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal);
- this.loanProductDetailsForm.get('glAccount')?.setValue('');
+ // A sub type belongs to one group, so changing the group clears it.
+ this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => {
+ this.loanProductDetailsForm.get('productTypeSubGroup')?.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);
+ }
});
}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html
new file mode 100644
index 000000000..72b1d72d7
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Single disbursement
+ Full amount paid out once.
+
+
+ Multiple tranches
+ Paid out in stages, for example as construction progresses.
+
+
+
+ @if (isMultiTranche) {
+
+ Maximum Tranches
+
+ At least 2 tranches
+
+ }
+
+ Disbursement Mode
+
+ @for (opt of disbursementModeOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Interest on Undisbursed Amount
+
+ @for (opt of undisbursedInterestOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+ Pre-EMI interest is charged on each disbursed tranche until the first full EMI
+
+
+
+
+
+
+
+ Hidden for Loans A/C: Facility Type (Demand / Overdraft), Drawing power, Limit renewal, Cheque
+ book.
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts
new file mode 100644
index 000000000..ff547810f
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts
@@ -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();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html
new file mode 100644
index 000000000..0a08d2380
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.html
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+ Fixed
+ Same rate for the whole tenure
+
+
+ Floating
+ Linked to a benchmark; resets periodically
+
+
+ Hybrid
+ Fixed for a period, then floating
+
+
+
+
+
+
+
+
+
+
+
+
+ Apply CIBIL Adjustment
+
+ @for (opt of yesNoOptions; track opt) {
+ {{ opt }}
+ }
+
+
+
+ Spread over Register Rate (%)
+
+
+
+ Rate Resolved At
+
+ @for (opt of rateResolvedAtOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+
+
+
+
+ Calculation Method
+
+ @for (opt of calcMethodOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Days in Year
+
+ @for (opt of daysInYearOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Days in Month
+
+ @for (opt of daysInMonthOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Calculation Period
+
+ @for (opt of calcPeriodOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Interest Starts From
+
+ @for (opt of interestStartOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Compounding
+
+ @for (opt of compoundingOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+ @if (isFloatingOrHybrid) {
+
+
+
+
+ Benchmark
+
+ @for (opt of benchmarkOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Reset Frequency
+
+ @for (opt of resetFrequencyOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ On Reset, Change
+
+ @for (opt of resetChangeOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+ }
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts
new file mode 100644
index 000000000..a29bc3534
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component.ts
@@ -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();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html
new file mode 100644
index 000000000..b17ca654c
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+ Part-prepayment allowed
+
+
+
+ Minimum Part-payment (₹)
+
+
+
+ Allowed Only On
+
+ @for (opt of allowedOnOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Part-payments per Year
+
+ @for (opt of perYearOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+ Reduce tenure
+ EMI stays the same
+
+
+ Reduce EMI
+ Tenure stays the same
+
+
+ Borrower chooses
+ Picked at payment time
+
+
+
+
+
+
+
+
+
+
+
+ Auto-close on Zero Balance
+
+ @for (opt of autoCloseOptions; track opt) {
+ {{ opt }}
+ }
+
+
+
+ NOC / Closure Letter
+
+ @for (opt of nocOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Restructuring / Re-schedule
+
+ @for (opt of restructuringOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+ Release collateral and lien on closure
+ Top-up loan on the same account
+ Allow balance transfer (takeover) out
+
+
+
+
Unlike an Advance, there is no renewal. A loan ends at maturity or on pre-closure.
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts
new file mode 100644
index 000000000..e5c6487e8
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts
@@ -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();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html
new file mode 100644
index 000000000..4972737f7
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+ @for (opt of repaymentTypeOptions; track opt.id) {
+
+ {{ opt.label }}
+ {{ opt.hint }}
+
+ }
+
+
+
+ This is the main difference from an Advance. Repayment follows a fixed schedule, not a running balance.
+
+
+
+
+
+
+
+
+
+
+ Repayment Mode
+
+ @for (opt of repaymentModeOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Linked Account Type
+
+ @for (opt of linkedAccountOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ PDC Collection
+
+ @for (opt of pdcOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+ Mandate registration is required before the first disbursement
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+ Appropriation Order
+
+ @for (opt of appropriationOrderOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Advance EMI Payment
+
+ @for (opt of advanceEmiOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts
new file mode 100644
index 000000000..162f2cd8d
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts
@@ -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();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html
new file mode 100644
index 000000000..6650b8f5a
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+ Security
+ Allowed
+ Max LTV %
+ Valuation
+ Insurance
+ Charge / lien
+
+
+
+ @for (rule of collateralRules; track rule.security) {
+
+ {{ rule.security }}
+
+ {{ rule.allowed ? 'Yes' : 'No' }}
+
+ {{ rule.ltv }}
+ {{ rule.valuation }}
+ {{ rule.insurance }}
+ {{ rule.charge }}
+
+ }
+
+
+
+
+
+
+
+
+ Guarantors Required (min)
+
+
+
+ Co-applicant
+
+ @for (opt of coApplicantOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Third-party Security
+
+ @for (opt of thirdPartyOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+
+
+
+
+
+ Legal opinion / title search mandatory before sanction
+
+
+ Property insurance mandatory for the whole tenure
+
+ Register charge (CERSAI) before disbursement
+ Release security automatically on closure
+ Allow multiple securities for one loan
+ Allow third-party deposit as security
+
+
+
+
+ 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.
+
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts
new file mode 100644
index 000000000..39caf70e0
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts
@@ -0,0 +1,127 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject } from '@angular/core';
+import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
+import { MatCheckbox } from '@angular/material/checkbox';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+/**
+ * Security & Collateral step for a Loans A/C (EMI) product (step 3).
+ *
+ * Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
+ * limit. UI-only for now: not sent in the create payload.
+ */
+@Component({
+ selector: 'mifosx-loan-product-loan-security-step',
+ templateUrl: './loan-product-loan-security-step.component.html',
+ styleUrls: ['../advance-steps.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ MatCheckbox
+ ]
+})
+export class LoanProductLoanSecurityStepComponent {
+ private formBuilder = inject(UntypedFormBuilder);
+
+ loanProductLoanSecurityForm: UntypedFormGroup;
+
+ readonly securityMandatoryOptions = [
+ { id: 'yes', label: 'Yes — no sanction without security' },
+ { id: 'no', label: 'No — unsecured' }
+ ];
+
+ readonly coApplicantOptions = [
+ { id: 'joint_property', label: 'Mandatory for joint property' },
+ { id: 'always', label: 'Always mandatory' },
+ { id: 'optional', label: 'Optional' }
+ ];
+
+ readonly thirdPartyOptions = [
+ { id: 'consent', label: 'Allowed with owner consent' },
+ { id: 'no', label: 'Not allowed' }
+ ];
+
+ /** Reference table: which security a loan on this product may accept. */
+ readonly collateralRules = [
+ {
+ security: 'Residential property',
+ allowed: true,
+ ltv: '80',
+ valuation: 'Empanelled valuer',
+ insurance: 'Mandatory',
+ charge: 'Mortgage + CERSAI'
+ },
+ {
+ security: 'Commercial property',
+ allowed: true,
+ ltv: '70',
+ valuation: 'Empanelled valuer',
+ insurance: 'Mandatory',
+ charge: 'Mortgage + CERSAI'
+ },
+ {
+ security: 'Fixed / Recurring Deposit',
+ allowed: true,
+ ltv: '90',
+ valuation: 'Not needed',
+ insurance: 'Not needed',
+ charge: 'Lien marked'
+ },
+ {
+ security: 'LIC policy',
+ allowed: true,
+ ltv: '90',
+ valuation: 'Surrender value',
+ insurance: 'Not needed',
+ charge: 'Assignment'
+ },
+ { security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' },
+ { security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }
+ ];
+
+ constructor() {
+ this.loanProductLoanSecurityForm = this.formBuilder.group({
+ securityType: ['Immovable property (mortgage)'],
+ securityMandatory: ['yes'],
+ margin: [
+ 20,
+ [
+ Validators.required,
+ Validators.min(0),
+ Validators.max(100)
+ ]
+ ],
+ guarantorsRequired: [
+ 1,
+ Validators.min(0)
+ ],
+ coApplicant: ['joint_property'],
+ thirdPartySecurity: ['consent'],
+ legalOpinionRequired: [true],
+ propertyInsuranceRequired: [true],
+ registerCharge: [true],
+ autoReleaseOnClosure: [true],
+ allowMultipleSecurities: [false],
+ allowThirdPartyDeposit: [false]
+ });
+ }
+
+ /** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
+ get ltv(): number {
+ const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
+ return Number.isFinite(margin) ? Math.max(0, 100 - margin) : 100;
+ }
+
+ get loanProductLoanSecurity() {
+ return {
+ ...this.loanProductLoanSecurityForm.getRawValue(),
+ ltv: this.ltv
+ };
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html
new file mode 100644
index 000000000..30c8eda8e
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+ Penal Charges Applicable
+
+ @for (opt of yesNoOptions; track opt) {
+ {{ opt }}
+ }
+
+
+
+ Penal Rate (% p.a.)
+
+
+
+ Applied On
+
+ @for (opt of appliedOnOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Penal Grace Days
+
+
+
+
+ RBI: penal charges are kept separate from interest, never capitalised, and shown in the Key Fact Statement.
+
+
+
+
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts
new file mode 100644
index 000000000..0df4451de
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts
@@ -0,0 +1,90 @@
+/**
+ * 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 { MatCheckbox } from '@angular/material/checkbox';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+/**
+ * Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
+ * UI-only for now — not sent in the create payload.
+ */
+@Component({
+ selector: 'mifosx-loan-product-penal-npa-step',
+ templateUrl: './loan-product-penal-npa-step.component.html',
+ styleUrls: ['../advance-steps.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ MatCheckbox
+ ]
+})
+export class LoanProductPenalNpaStepComponent {
+ private formBuilder = inject(UntypedFormBuilder);
+
+ @Input() loanProductsTemplate: any;
+
+ loanProductPenalNpaForm: UntypedFormGroup;
+
+ readonly yesNoOptions = [
+ 'YES',
+ 'NO'
+ ];
+
+ readonly appliedOnOptions = [
+ { id: 'overdue', label: 'Overdue amount' },
+ { id: 'outstanding', label: 'Full outstanding' }
+ ];
+
+ readonly npaTriggerOptions = [
+ { id: 'maturity', label: 'Dues overdue after maturity' },
+ { id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
+ ];
+
+ readonly interestAfterNpaOptions = [
+ { id: 'undebited', label: 'Move to Undebited Interest GL' },
+ { id: 'suspense', label: 'Move to Interest Suspense GL' }
+ ];
+
+ get delinquencyBucketOptions(): any[] {
+ return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
+ }
+
+ constructor() {
+ this.loanProductPenalNpaForm = this.formBuilder.group({
+ penalApplicable: ['YES'],
+ penalRate: [
+ 2.0,
+ [
+ Validators.min(0),
+ Validators.max(100)
+ ]
+ ],
+ penalAppliedOn: ['overdue'],
+ penalGraceDays: [0],
+ delinquencyBucketId: [''],
+ npaDays: [
+ 90,
+ [
+ Validators.required,
+ Validators.min(0)
+ ]
+ ],
+ npaTrigger: ['maturity'],
+ interestAfterNpa: ['undebited'],
+ graceOnArrearsAgeing: [0],
+ inArrearsTolerance: [0],
+ upgradeOnlyAllArrears: [true],
+ keepStandardIfDepositCovers: [true]
+ });
+ }
+
+ get loanProductPenalNpa() {
+ return this.loanProductPenalNpaForm.getRawValue();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html
new file mode 100644
index 000000000..3a062eb6a
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+ Pre-closure Allowed
+
+ @for (opt of preclosureOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+ Lock-in Period (months)
+
+
+
+ Interest on Pre-closure
+
+ @for (opt of preclosureInterestOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+
+
+
+ Renewal and pre-closure fees are set once, on the Charges step.
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts
new file mode 100644
index 000000000..984ade3bc
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component.ts
@@ -0,0 +1,64 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject } from '@angular/core';
+import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+/**
+ * Limit renewal & pre-closure rules for an Advances A/C product (Figma step 7). Fees live on the
+ * Charges step. UI-only for now — not sent in the create payload.
+ */
+@Component({
+ selector: 'mifosx-loan-product-renewal-closure-step',
+ templateUrl: './loan-product-renewal-closure-step.component.html',
+ styleUrls: ['../advance-steps.scss'],
+ imports: [...STANDALONE_SHARED_IMPORTS]
+})
+export class LoanProductRenewalClosureStepComponent {
+ private formBuilder = inject(UntypedFormBuilder);
+
+ loanProductRenewalClosureForm: UntypedFormGroup;
+
+ readonly yesNoOptions = [
+ 'YES',
+ 'NO'
+ ];
+
+ readonly preclosureOptions = [
+ { id: 'anytime', label: 'Anytime' },
+ { id: 'after_lockin', label: 'After lock-in period' },
+ { id: 'not_allowed', label: 'Not allowed' }
+ ];
+
+ readonly preclosureInterestOptions = [
+ { id: 'till_closing', label: 'Till closing date (broken period)' },
+ { id: 'full_period', label: 'Full current period' }
+ ];
+
+ constructor() {
+ this.loanProductRenewalClosureForm = this.formBuilder.group({
+ renewalAllowed: ['YES'],
+ renewalPeriodMonths: [
+ 12,
+ [
+ Validators.required,
+ Validators.min(1)
+ ]
+ ],
+ renewalGraceDays: [30],
+ preclosureAllowed: ['anytime'],
+ lockInMonths: [0],
+ interestOnPreclosure: ['till_closing']
+ });
+ }
+
+ get loanProductRenewalClosure() {
+ return this.loanProductRenewalClosureForm.getRawValue();
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html
new file mode 100644
index 000000000..c2410aea5
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html
@@ -0,0 +1,160 @@
+
+
+
+ @if (!isAdvance) {
+
+
+ Security & Lien applies to an Advances A/C product. Choose it on the Details step.
+
+ }
+
+ @if (isAdvance) {
+
+
+ @if (isLienSubType) {
+
+
+
+
+
Lien marking
+
+
+ Lien Required
+
+
+
+
+ Lien Marking Stage
+
+ @for (opt of lienMarkingStageOptions; track opt.id) {
+ {{ opt.label }}
+ }
+
+
+
+
+ Eligible Deposit Products
+
+ @for (product of depositProductOptions; track product) {
+ {{ product }}
+ }
+
+ Only accounts of these deposit products can be pledged for this advance
+ Pick at least one deposit product
+
+
+
+
+
+
Rules
+
+
+ Tenure cannot exceed deposit maturity date
+
+
+ Auto-release lien when the advance is closed
+
+
+ Allow multiple deposits for one advance
+
+
+ Adjust advance from deposit proceeds at maturity
+
+
+ Allow third-party deposit (consent letter required)
+
+
+ Allow deposit renewal under lien (lien carries forward)
+
+
+
+
+
+
Lien certificate
+
+
+ Lien Certificate Required
+
+ @for (opt of yesNoOptions; track opt) {
+ {{ opt }}
+ }
+
+
+
+ @if (loanProductSecurityLienForm.get('lienCertificateRequired').value === 'YES') {
+
+ Certificate No. Prefix
+
+ Prefix is required
+
+
+
+ Print on Disbursement
+
+ @for (opt of yesNoOptions; track opt) {
+ {{ opt }}
+ }
+
+
+ }
+
+
+
+
+
+
+ Interest basis and spread are set on the Interest step; the lien-certificate fee on the
+ Charges step.
+
+ }
+ }
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss
new file mode 100644
index 000000000..7d57dfc5e
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss
@@ -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/.
+ */
+
+$accent: #0ea5e9;
+$border: #e2e8f0;
+$gray-50: #f8f9fa;
+$gray-200: #e8eaed;
+$gray-900: #202124;
+
+.lsl-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;
+ }
+}
+
+.lsl-subsection {
+ padding: 16px 24px;
+
+ & + & {
+ border-top: 1px solid $border;
+ }
+}
+
+.lsl-group {
+ margin: 0 0 12px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #5f6368;
+}
+
+.lsl-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 4px 16px;
+ padding: 16px 24px;
+
+ @media (width <= 600px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.lsl-subsection .lsl-grid {
+ padding: 0;
+}
+
+.lsl-field {
+ width: 100%;
+
+ &--full {
+ grid-column: 1 / -1;
+ }
+}
+
+.lsl-checks {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px 32px;
+
+ @media (width <= 600px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.lsl-note {
+ 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;
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts
new file mode 100644
index 000000000..d0327c94a
--- /dev/null
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts
@@ -0,0 +1,196 @@
+/**
+ * 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, Input, inject } from '@angular/core';
+import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
+import { MatTooltip } from '@angular/material/tooltip';
+import { MatCheckbox } from '@angular/material/checkbox';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+/**
+ * Security & Lien step, shown only for an "Advances A/C" product.
+ *
+ * For a deposit-backed advance (Loan against FD / RD / DDS / LIC) the bank marks a lien on the
+ * customer's own deposit. This step captures the product-level rules for that: the margin (from which
+ * the LTV and drawing power follow), which deposit products may be pledged, and the lien certificate.
+ *
+ * UI-only for now: its values are NOT sent in the create payload, because the backend has no column to
+ * store them yet. Once the satellite table lands, wire `loanProductSecurityLien` into the payload.
+ */
+@Component({
+ selector: 'mifosx-loan-product-security-lien-step',
+ templateUrl: './loan-product-security-lien-step.component.html',
+ styleUrls: ['./loan-product-security-lien-step.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ MatTooltip,
+ MatCheckbox
+ ]
+})
+export class LoanProductSecurityLienStepComponent implements OnInit {
+ private formBuilder = inject(UntypedFormBuilder);
+
+ @Input() loanProductsTemplate: any;
+ /** The Details step's Product Type controls, so this step reacts to the chosen group / sub type. */
+ @Input() productTypeGroup: AbstractControl;
+ @Input() productTypeSubGroup: AbstractControl;
+
+ loanProductSecurityLienForm: UntypedFormGroup;
+
+ /** Sub type -> the security it is backed by. Only deposit / policy sub types get a lien section. */
+ private readonly securityTypeBySubGroup: Record = {
+ fd: 'Fixed Deposit',
+ rd: 'Recurring Deposit',
+ dds: 'Daily Deposit Scheme',
+ lic: 'LIC Policy',
+ gold: 'Gold Ornaments',
+ warehouse: 'Warehouse Receipt',
+ cc: 'Stock / Book Debts',
+ overdraft: 'Deposit / Property'
+ };
+
+ /** Sub types whose security is lien-marked on a deposit (rather than pledged / hypothecated). */
+ private readonly lienSubGroups = [
+ 'fd',
+ 'rd',
+ 'dds',
+ 'lic'
+ ];
+
+ /** Default margin % per sub type (bank policy in real use); LTV is derived from it. */
+ private readonly defaultMarginBySubGroup: Record = {
+ fd: 10,
+ rd: 10,
+ dds: 10,
+ lic: 40
+ };
+
+ readonly eligibleAmountBasisOptions = [
+ { id: 'balance_accrued', label: 'Deposit balance + accrued interest' },
+ { id: 'balance', label: 'Deposit balance only' },
+ { id: 'surrender', label: 'Surrender value (LIC)' }
+ ];
+
+ readonly lienMarkingStageOptions = [
+ { id: 'disbursement', label: 'At Disbursement' },
+ { id: 'sanction', label: 'At Sanction' }
+ ];
+
+ readonly depositProductOptions = [
+ 'Fixed Deposit – Regular',
+ 'Fixed Deposit – Senior Citizen',
+ 'Recurring Deposit – Regular',
+ 'Daily Deposit Scheme'
+ ];
+
+ readonly yesNoOptions = [
+ 'YES',
+ 'NO'
+ ];
+
+ constructor() {
+ this.createForm();
+ }
+
+ ngOnInit() {
+ this.applySubGroup(this.productTypeSubGroup?.value);
+ // Keep Security Type and the default margin in step with the sub type chosen on the Details step.
+ this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
+ }
+
+ private applySubGroup(subGroup: string): void {
+ this.loanProductSecurityLienForm
+ .get('securityType')
+ .setValue(this.securityTypeBySubGroup[subGroup] ?? '', { emitEvent: false });
+ const margin = this.defaultMarginBySubGroup[subGroup];
+ if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
+ this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false });
+ }
+ }
+
+ /** This whole step is only relevant to an Advances A/C product. */
+ get isAdvance(): boolean {
+ return this.productTypeGroup?.value === 'advances';
+ }
+
+ /** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */
+ get isLienSubType(): boolean {
+ return this.lienSubGroups.includes(this.productTypeSubGroup?.value);
+ }
+
+ /** LTV = 100 − margin, shown read-only next to the margin. */
+ get ltv(): number | null {
+ const margin = this.loanProductSecurityLienForm.get('margin').value;
+ if (margin === null || margin === '' || isNaN(margin)) {
+ return null;
+ }
+ return 100 - Number(margin);
+ }
+
+ createForm() {
+ this.loanProductSecurityLienForm = this.formBuilder.group({
+ securityType: [{ value: '', disabled: true }],
+ eligibleAmountBasis: [
+ 'balance_accrued',
+ Validators.required
+ ],
+ margin: [
+ 10,
+ [
+ Validators.required,
+ Validators.min(0),
+ Validators.max(100)
+ ]
+ ],
+ lienMarkingStage: [
+ 'disbursement',
+ Validators.required
+ ],
+ eligibleDepositProducts: [
+ ['Fixed Deposit – Regular'],
+ Validators.required
+ ],
+ tenureNotExceedMaturity: [true],
+ autoReleaseOnClosure: [true],
+ allowMultipleDeposits: [false],
+ adjustFromMaturity: [true],
+ allowThirdPartyDeposit: [false],
+ allowRenewalUnderLien: [true],
+ lienCertificateRequired: ['YES'],
+ lienCertificatePrefix: [
+ 'LC/FD/',
+ Validators.required
+ ],
+ printOnDisbursement: ['YES']
+ });
+ }
+
+ /** Flat values for the payload. Not wired into submit yet — see the class comment. */
+ get loanProductSecurityLien() {
+ const value = this.loanProductSecurityLienForm.getRawValue();
+ return {
+ securityType: value.securityType,
+ eligibleAmountBasis: value.eligibleAmountBasis,
+ margin: value.margin,
+ ltv: this.ltv,
+ lienMarkingStage: value.lienMarkingStage,
+ eligibleDepositProducts: value.eligibleDepositProducts,
+ lienRules: {
+ tenureNotExceedMaturity: value.tenureNotExceedMaturity,
+ autoReleaseOnClosure: value.autoReleaseOnClosure,
+ allowMultipleDeposits: value.allowMultipleDeposits,
+ adjustFromMaturity: value.adjustFromMaturity,
+ allowThirdPartyDeposit: value.allowThirdPartyDeposit,
+ allowRenewalUnderLien: value.allowRenewalUnderLien
+ },
+ lienCertificateRequired: value.lienCertificateRequired,
+ lienCertificatePrefix: value.lienCertificatePrefix,
+ printOnDisbursement: value.printOnDisbursement
+ };
+ }
+}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html
index 5853a8895..c255df105 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.html
@@ -7,1100 +7,1212 @@
-->
-
-
-
-
-
- {{ 'labels.inputs.products.loan.Amortization' | translate }}
-
- @for (amortizationType of amortizationTypeData; track amortizationType) {
-
- {{ 'labels.inputs.products.loan.amortizationType.' + amortizationType.value | translate }}
-
- }
-
-
- {{ 'labels.inputs.products.loan.Amortization' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.required' | translate }}
-
-
-
-
- {{ 'labels.inputs.Interest Method' | translate }}
-
- @for (interestType of interestTypeData; track interestType) {
-
- {{ interestType.value | translateKey: 'catalogs' }}
-
- }
-
-
- {{ 'labels.inputs.Interest Method' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.required' | translate }}
-
-
-
-
- {{ 'labels.inputs.Interest calculation period' | translate }}
-
- @for (
- interestCalculationPeriodType of interestCalculationPeriodTypeData;
- track interestCalculationPeriodType
- ) {
-
- {{ interestCalculationPeriodType.value | translateKey: 'catalogs' }}
-
- }
-
-
- {{ 'labels.inputs.Interest calculation period' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.required' | translate }}
-
-
-
-
-
- {{ 'labels.inputs.products.loan.Is Equal Amortization?' | translate }}
-
-
-
- @if (loanProductSettingsForm.value.interestCalculationPeriodType === 1) {
-
-
- {{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }}
-
-
- }
-
-
-
-
-
-
-
-
- {{ 'labels.inputs.Loan Schedule Type' | translate }}
-
- @for (loanScheduleType of loanScheduleTypeData; track loanScheduleType) {
-
- {{ loanScheduleType.value | translateKey: 'catalogs' }}
-
- }
-
-
-
-
- {{ 'labels.inputs.Repayment strategy' | translate }}
-
- @for (
- transactionProcessingStrategy of transactionProcessingStrategyData;
- track transactionProcessingStrategy
- ) {
-
- {{ transactionProcessingStrategy.name | translateKey: 'catalogs' }}
-
- }
-
-
- {{ 'labels.inputs.Repayment strategy' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.required' | translate }}
-
-
-
- @if (isAdvancedTransactionProcessingStrategy) {
+
+
+
+
+
+
+ {{ 'labels.inputs.products.loan.Amortization' | translate }} &
+ {{ 'labels.inputs.Interest Method' | translate }}
+
+
+
- {{ 'labels.inputs.Loan Schedule Processing Type' | translate }}
-
- @for (loanScheduleProcessingType of loanScheduleProcessingTypeData; track loanScheduleProcessingType) {
-
- {{ loanScheduleProcessingType.value | translateKey: 'catalogs' }}
-
- }
-
-
- }
-
-
- {{ 'labels.inputs.Interest free period' | translate }}
-
-
-
-
- Allowed pending EMI amount
-
-
-
-
- {{ 'labels.inputs.Days in year' | translate }}
-
- @for (daysInYearType of daysInYearTypeData; track daysInYearType) {
-
- {{ daysInYearType.value | translateKey: 'catalogs' }}
-
- }
-
-
- {{ 'labels.inputs.Days in year' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.required' | translate }}
-
-
-
- @if (useDaysInYearCustomStrategy) {
-
- {{ 'labels.inputs.Days in year custom strategy' | translate }}
+ {{ 'labels.inputs.products.loan.Amortization' | translate }}
- @for (daysInYearCustomStrategy of daysInYearCustomStrategyOptions; track daysInYearCustomStrategy) {
-
- {{ daysInYearCustomStrategy.value | translateKey: 'catalogs' }}
+ @for (amortizationType of amortizationTypeData; track amortizationType) {
+
+ {{ 'labels.inputs.products.loan.amortizationType.' + amortizationType.value | translate }}
}
+
+ {{ 'labels.inputs.products.loan.Amortization' | translate }} {{ 'labels.commons.is' | translate }}
+ {{ 'labels.commons.required' | translate }}
+
- }
-
- {{ 'labels.inputs.Days in month' | translate }}
-
- @for (daysInMonthType of daysInMonthTypeData; track daysInMonthType) {
-
- {{ daysInMonthType.value | translateKey: 'catalogs' }}
-
- }
-
-
- {{ 'labels.inputs.Days in month' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.required' | translate }}
-
-
-
-
-
- {{ 'labels.inputs.Allow fixing of the installment amount' | translate }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Penal Charge Method
-
- RBI Compliant Penal Charges (Non-Compounding)
- Legacy Penal Interest (Compounding)
-
-
-
-
-
- Is Floating Rate Loan? (Individual)
-
-
-
-
- Foreclosure Charges Applicable
-
- YES
- NO
-
- RBI: No charges for Floating Rate Loans to Individuals
-
-
-
-
- Enable Key Fact Statement (KFS)
-
-
-
-
-
- Calculate APR
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ 'labels.inputs.Grace on principal payment' | translate }}
-
-
-
-
- {{ 'labels.inputs.Grace on interest payment' | translate }}
-
-
-
-
- {{ 'labels.inputs.Delinquency Bucket' | translate }}
-
- @for (delinquencyBucket of delinquencyBucketData; track delinquencyBucket) {
-
- {{ delinquencyBucket.name }}
-
- }
-
- @if (loanProductSettingsForm.controls.delinquencyBucketId) {
-
-
-
- }
-
-
- @if (loanProductSettingsForm.value.delinquencyBucketId) {
-
-
- {{ 'labels.inputs.Enable installment level Delinquency' | translate }}
+
+
+ {{ 'labels.inputs.products.loan.Is Equal Amortization?' | translate }}
- }
-
-
-
-
-
-
-
- {{ 'labels.inputs.On arrears ageing' | translate }}
-
-
-
-
- {{ 'labels.inputs.Overdue days for NPA' | translate }}
-
-
-
-
-
- {{ 'labels.inputs.Account moves out of NPA only after all arrears have been cleared' | translate }}
-
+ @if (loanProductSettingsForm.value.interestCalculationPeriodType === 1) {
+
+
+ {{ 'labels.inputs.Calculate interest for exact days in partial period' | translate }}
+
+
+ }
+
-
- {{ 'labels.inputs.Principal Threshold (%) for Last Instalment' | translate }}
-
-
-
-
-
-
-
-
-
-
-
- {{ 'labels.inputs.Are Variable Installments allowed?' | translate }}
-
-
-
- @if (loanProductSettingsForm.value.allowVariableInstallments) {
+
+
+
+
+
+ {{ 'labels.inputs.Loan Schedule' | translate }}
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ 'labels.inputs.Allow overriding select terms and settings in loan accounts' | translate }}
-
-
-
- @if (loanProductSettingsForm.value.allowAttributeConfiguration) {
-
-
-
- {{ 'labels.inputs.Amortization' | translate }}
-
-
-
-
- {{ 'labels.inputs.Interest method' | translate }}
-
-
-
-
- {{ 'labels.inputs.Repayment strategy' | translate }}
-
-
-
-
- {{ 'labels.inputs.Interest calculation period' | translate }}
-
-
-
-
- Allowed pending EMI amount
-
-
-
-
- {{ 'labels.inputs.Repaid every' | translate }}
-
-
-
-
- {{ 'labels.inputs.Moratorium' | translate }}
-
-
-
-
- {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }}
-
-
+
+
+
+
+
+ {{ 'labels.heading.Guarantee Requirements' | translate }}
+
+
+
+
+
+ {{ 'labels.inputs.Place Guarantee Funds On-Hold' | translate }}
+
- }
-
-
+
+ @if (loanProductSettingsForm.value.holdGuaranteeFunds) {
+
+ {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }}
+
+
+ {{ 'labels.inputs.Mandatory Guarantee(%)' | translate }} {{ 'labels.commons.is' | translate }}
+ {{ 'labels.commons.required' | translate }}
+
+
+
+ {{ 'labels.inputs.Minimum Guarantee from Own Funds(%)' | translate }}
+
+
+
+ {{ 'labels.inputs.Minimum Guarantee from Guarantor Funds(%)' | translate }}
+
+
+ }
+
+
+
+
+
+
+
+
+ {{ 'labels.inputs.Configurable Terms and Settings' | translate }}
+
+
+
+
+
+ {{ 'labels.inputs.Allow overriding select terms and settings in loan accounts' | translate }}
+
+
+
+ @if (loanProductSettingsForm.value.allowAttributeConfiguration) {
+
+
+
+ {{ 'labels.inputs.Amortization' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Interest method' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Repayment strategy' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Interest calculation period' | translate }}
+
+
+
+
+ Allowed pending EMI amount
+
+
+
+
+ {{ 'labels.inputs.Repaid every' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Moratorium' | translate }}
+
+
+
+
+ {{ 'labels.inputs.Number of days a loan may be overdue before moving into arrears' | translate }}
+
+
+
+ }
+
+
+
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss
index 50934aecc..c14c4b170 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.scss
@@ -71,6 +71,16 @@
}
}
+.lps-panel {
+ & + & {
+ margin-top: 12px;
+ }
+
+ .lps-section__body {
+ padding-top: 8px;
+ }
+}
+
.lps-full-width {
grid-column: 1 / -1;
}
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts
index d230afc79..5153727bf 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component.ts
@@ -22,6 +22,12 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import {
+ MatAccordion,
+ MatExpansionPanel,
+ MatExpansionPanelHeader,
+ MatExpansionPanelTitle
+} from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -35,7 +41,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox,
MatIconButton,
FaIconComponent,
- ReactiveFormsModule
+ ReactiveFormsModule,
+ MatAccordion,
+ MatExpansionPanel,
+ MatExpansionPanelHeader,
+ MatExpansionPanelTitle
]
})
export class LoanProductSettingsStepComponent implements OnInit {
@@ -44,9 +54,35 @@ export class LoanProductSettingsStepComponent implements OnInit {
@Input() toEdit: boolean;
@Input() loanProductsTemplate: any;
- @Input() isLinkedToFloatingInterestRates: UntypedFormControl;
+ @Input() productTypeGroup: UntypedFormControl;
@Output() advancePaymentStrategy = new EventEmitter
();
+ /**
+ * Provided from the Terms step's control. It can arrive after this component's ngOnInit (the Terms tab
+ * resolves later now that the tabs are conditional), so subscribe from the setter when it is present
+ * rather than assuming it in ngOnInit — otherwise a null control blanks the whole wizard.
+ */
+ private _isLinkedToFloatingInterestRates: UntypedFormControl;
+ private isLinkedSubscribed = false;
+
+ @Input()
+ set isLinkedToFloatingInterestRates(control: UntypedFormControl) {
+ this._isLinkedToFloatingInterestRates = control;
+ if (control && !this.isLinkedSubscribed) {
+ this.isLinkedSubscribed = true;
+ control.valueChanges.subscribe((isLinked: any) => {
+ if (isLinked) {
+ this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
+ this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
+ }
+ });
+ }
+ }
+
+ get isLinkedToFloatingInterestRates(): UntypedFormControl {
+ return this._isLinkedToFloatingInterestRates;
+ }
+
loanProductSettingsForm: UntypedFormGroup;
amortizationTypeData: any;
@@ -74,6 +110,15 @@ export class LoanProductSettingsStepComponent implements OnInit {
daysInYearCustomStrategyOptions: OptionData[] = [];
useDaysInYearCustomStrategy = false;
+ /** True when the product being created is an "Advances A/C" (no-EMI, limit-based) product. */
+ isAdvanceAccount = false;
+
+ /** UI-only for now; not yet wired into the submitted payload (see loan-products.ts buildPayload). */
+ readonly loanPeriodOptions = [
+ { value: 12, label: '12 Months' },
+ { value: 15, label: '15 Months' }
+ ];
+
/** Values to Days for Repayments */
defaultConfigValues: GlobalConfiguration[] = [];
@@ -84,12 +129,13 @@ export class LoanProductSettingsStepComponent implements OnInit {
ngOnInit() {
this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault'];
- this.isLinkedToFloatingInterestRates.valueChanges.subscribe((isLinkedToFloatingInterestRates: any) => {
- if (isLinkedToFloatingInterestRates) {
- this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
- this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
- }
- });
+
+ if (this.productTypeGroup) {
+ this.applyAdvanceAccountRules(this.productTypeGroup.value === 'advances');
+ this.productTypeGroup.valueChanges.subscribe((productTypeGroup: string) => {
+ this.applyAdvanceAccountRules(productTypeGroup === 'advances');
+ });
+ }
this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions;
this.interestTypeData = this.loanProductsTemplate.interestTypeOptions;
@@ -334,6 +380,8 @@ export class LoanProductSettingsStepComponent implements OnInit {
Validators.required
],
allowAccrualPostingInArrears: [false],
+ // Advances A/C only; UI-only for now, stripped from the payload in loan-products.ts buildPayload().
+ loanPeriod: [''],
// Cheque Book Settings
chequeBookIssueEnabled: ['NO'],
@@ -453,6 +501,28 @@ export class LoanProductSettingsStepComponent implements OnInit {
}
}
+ /**
+ * Advances A/C has no EMI, so the Loan Schedule section is simplified: schedule type is locked to
+ * Cumulative (Progressive only makes sense with Advanced Payment Allocation, which is an EMI concept),
+ * installment-amount fixing doesn't apply, and Days in year/month are replaced by a placeholder
+ * Loan Period pick. loanScheduleType/canDefineInstallmentAmount stay enabled (not `.disable()`d) so they
+ * remain in `loanProductSettingsForm.value` and the submitted payload is unaffected by this UI change.
+ */
+ private applyAdvanceAccountRules(isAdvance: boolean): void {
+ this.isAdvanceAccount = isAdvance;
+ const loanPeriodControl = this.loanProductSettingsForm.get('loanPeriod');
+ if (isAdvance) {
+ this.loanProductSettingsForm.patchValue({
+ loanScheduleType: LoanProducts.LOAN_SCHEDULE_TYPE_CUMULATIVE,
+ canDefineInstallmentAmount: false
+ });
+ loanPeriodControl.setValidators(Validators.required);
+ } else {
+ loanPeriodControl.clearValidators();
+ }
+ loanPeriodControl.updateValueAndValidity();
+ }
+
setConditionalControls() {
const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides');
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html
index 6e765fbe3..7b68bec2d 100644
--- a/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html
+++ b/src/app/products/loan-products/loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component.html
@@ -264,6 +264,62 @@
}
+
+
+
+
+
+ Debit Int. Calculation
+
+ YES
+ NO
+
+
+
+
+ Separate Penal Int.
+
+ YES
+ NO
+
+
+
+
+ Credit Int. Calculation
+
+ YES
+ NO
+
+
+
+
+ Sub A/c Type Balance
+
+ YES
+ NO
+
+
+
+
+ Final Int. Rounding
+
+ YES
+ NO
+
+
+
+ @if (loanProductTermsForm.value.finalIntRounding) {
+
+ Rounding UpTo
+
+
+ }
+
+
+