diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts index 161f74dbf..eb5078526 100644 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts @@ -321,7 +321,8 @@ export class Form121TabComponent implements OnInit, OnDestroy { this.availableFys = [...fys].sort().reverse(); if (!this.availableFys.includes(this.fy)) { - this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? ''; + this.fy = + this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? ''; } } @@ -367,9 +368,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { : 0; } // What was actually debited for this customer as deductee, wherever the savings account sits. - this.actualTds = this.accountInputs - .filter((a) => a.meta.tdsApplicable) - .reduce((s, a) => s + a.meta.tdsDeducted, 0); + this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0); if (!this.dateOfBirth || this.configMissing) { return; diff --git a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html index 20d63ccac..e331d938c 100644 --- a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html +++ b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html @@ -74,9 +74,7 @@ (3%)" while Admin holds 4% states a rate the bank is not on. -->
CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}
{{ crore(crrRequired) }}Cr
-
- SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }} -
+
SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}
@@ -143,21 +141,22 @@ - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } - Total Demand Liabilities ({{ demandLiabilities.length }} accounts) - {{ totalOf(demandLiabilities, "openingValue") }} - {{ totalOf(demandLiabilities, "currentValue") }} + + Total Demand Liabilities ({{ demandLiabilities.length }} accounts) + + {{ totalOf(demandLiabilities, 'openingValue') }} + {{ totalOf(demandLiabilities, 'currentValue') }} {{ totalChange(demandLiabilities) }} @@ -206,21 +205,22 @@ - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } - Total Time Liabilities ({{ timeLiabilities.length }} accounts) - {{ totalOf(timeLiabilities, "openingValue") }} - {{ totalOf(timeLiabilities, "currentValue") }} + + Total Time Liabilities ({{ timeLiabilities.length }} accounts) + + {{ totalOf(timeLiabilities, 'openingValue') }} + {{ totalOf(timeLiabilities, 'currentValue') }} {{ totalChange(timeLiabilities) }} @@ -263,12 +263,11 @@ {{ item.status }} - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } @@ -278,8 +277,8 @@ Total Assets with Banking System ( Deducted from NDTL ) - {{ totalOf(bankingAssets, "openingValue") }} - {{ totalOf(bankingAssets, "currentValue") }} + {{ totalOf(bankingAssets, 'openingValue') }} + {{ totalOf(bankingAssets, 'currentValue') }} {{ totalChange(bankingAssets) }} @@ -328,12 +327,11 @@ {{ item.status }} - } - @empty { + } @empty { - No accounts are mapped to this head, so the return reports nothing under it. Map them in - Admin → RBI Reserve Configuration. + No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin → + RBI Reserve Configuration. } @@ -341,8 +339,8 @@ Total Liquid Assets (SLR Eligible) - {{ totalOf(liquidAssets, "openingValue") }} - {{ totalOf(liquidAssets, "currentValue") }} + {{ totalOf(liquidAssets, 'openingValue') }} + {{ totalOf(liquidAssets, 'currentValue') }} {{ totalChange(liquidAssets) }} diff --git a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts index 04770829f..6344659ad 100644 --- a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts +++ b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.ts @@ -128,8 +128,7 @@ export class CrrSlrDialogComponent implements OnInit { if (!columns.length) { this.unavailable = - data?.unmetRequirements?.[0] ?? - 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.'; + data?.unmetRequirements?.[0] ?? 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.'; return; } // The return computes but says something would make it wrong to file — a rate never set, a @@ -175,7 +174,6 @@ export class CrrSlrDialogComponent implements OnInit { }); } - /** * The four tabs, filled from the return's own lines. * diff --git a/src/app/home/dashboard/dashboard.component.html b/src/app/home/dashboard/dashboard.component.html index 371434f2f..f0889bd9d 100644 --- a/src/app/home/dashboard/dashboard.component.html +++ b/src/app/home/dashboard/dashboard.component.html @@ -33,11 +33,15 @@ rate Admin was on. -->
- CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }} + CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }} {{ crrSlr.crrRequired }}
- SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} + SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} {{ crrSlr.slrRequired }}
diff --git a/src/app/home/dashboard/dashboard.component.ts b/src/app/home/dashboard/dashboard.component.ts index 4b92acef6..c0605e700 100644 --- a/src/app/home/dashboard/dashboard.component.ts +++ b/src/app/home/dashboard/dashboard.component.ts @@ -116,7 +116,12 @@ export class DashboardComponent implements OnInit { const crrNeed = data?.partB?.required?.[latest]; const slrHeld = data?.partC?.maintained?.[latest]; const slrNeed = data?.partC?.required?.[latest]; - if ([crrHeld, crrNeed, slrHeld, slrNeed].every((v) => v !== null && v !== undefined)) { + if ([ + crrHeld, + crrNeed, + slrHeld, + slrNeed + ].every((v) => v !== null && v !== undefined)) { this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short'; } }, diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html new file mode 100644 index 000000000..20170e15f --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.html @@ -0,0 +1,43 @@ + + +@if (product) { +
+
+ {{ product.name }} + {{ category }} + {{ type }} +
+
+
+ Amount + + @if (product.minPrincipal != null || product.maxPrincipal != null) { + {{ product.minPrincipal | number: '1.0-0' }} – {{ product.maxPrincipal | number: '1.0-0' }} + } @else { + — + } + +
+
+ Tenure (repayments) + + @if (product.minNumberOfRepayments != null || product.maxNumberOfRepayments != null) { + {{ product.minNumberOfRepayments }} – {{ product.maxNumberOfRepayments }} + } @else { + — + } + +
+
+ Short name + {{ product.shortName || '—' }} +
+
+
+} diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss new file mode 100644 index 000000000..3e4824e67 --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.scss @@ -0,0 +1,52 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.lptc { + margin: 4px 0 16px; + padding: 12px 16px; + border: 1px solid #bae6fd; + border-radius: 8px; + background: #f0f9ff; + + &__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + &__chip { + padding: 1px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #075985; + font-size: 12px; + font-weight: 600; + + &--type { + background: #ede9fe; + color: #5b21b6; + } + } + + &__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 16px; + font-size: 13px; + color: #1e293b; + } + + &__label { + display: block; + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } +} diff --git a/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts new file mode 100644 index 000000000..be03d6c85 --- /dev/null +++ b/src/app/loans/loans-account-stepper/loan-product-template-card/loan-product-template-card.component.ts @@ -0,0 +1,37 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; +import { productCategoryLabel, productTypeLabel } from 'app/products/loan-products/loan-product-types'; + +/** + * The product picked on the loan account form, summarised with its category, type and the main limits, so the + * officer can see what the account will be opened under before going on. + */ +@Component({ + selector: 'mifosx-loan-product-template-card', + templateUrl: './loan-product-template-card.component.html', + styleUrls: ['./loan-product-template-card.component.scss'], + imports: [DecimalPipe] +}) +export class LoanProductTemplateCardComponent { + /** A product from the catalogue (the list shape), or undefined when none is picked. */ + @Input() product: any; + + get category(): string { + return productCategoryLabel(this.product?.advanceExtensions?.productTypeGroup); + } + + get type(): string { + return productTypeLabel( + this.product?.advanceExtensions?.productTypeGroup, + this.product?.advanceExtensions?.productTypeSubGroup + ); + } +} diff --git a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts index 8d649088c..e60f519d1 100644 --- a/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts +++ b/src/app/loans/loans-account-stepper/loans-account-details-step/loans-account-details-step.component.ts @@ -32,6 +32,9 @@ import { MatCheckbox } from '@angular/material/checkbox'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { LoanProductCatalogService } from '../../services/loan-product-catalog.service'; +import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types'; /** * Loans Account Details Step @@ -49,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatStepperPrevious, FaIconComponent, MatStepperNext, - AsyncPipe + AsyncPipe, + LoanProductTemplateCardComponent ] }) export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { @@ -59,6 +63,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { private translateService = inject(TranslateService); private settingsService = inject(SettingsService); private commons = inject(Commons); + private catalog = inject(LoanProductCatalogService); //** Defining PlaceHolders for the search bar */ placeHolderLabel = ''; @@ -88,6 +93,27 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { loanId: any = null; + /** Category and product type narrow the product picker to the active products of that kind. */ + readonly categories = PRODUCT_TYPE_GROUPS; + categoryFilter = ''; + typeFilter = ''; + /** Ids of the products active today; null until read, or if the read failed (then nothing is hidden). */ + private activeProductIds: Set | null = null; + /** Every product from the template, before any filter. */ + private allProducts: any[] = []; + + /** Types offered for the chosen category; every type when no category is chosen. */ + get typeOptions() { + return this.categoryFilter + ? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []) + : Object.values(PRODUCT_SUB_TYPES).flat(); + } + + /** The product picked in the form, with its category and type, for the template card. */ + get selectedProduct(): any { + return this.catalog.get(this.loansAccountDetailsForm?.get('productId')?.value); + } + loanProductSelected = false; /** Currency data. */ protected productData: ReplaySubject = new ReplaySubject(1); @@ -115,7 +141,8 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { this.maxDate = this.settingsService.maxFutureDate; this.buildDependencies(); if (this.loansAccountTemplate) { - this.productList = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name')); + this.allProducts = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name')); + this.productList = this.allProducts; if (this.loansAccountTemplate.loanProductId) { this.loansAccountDetailsForm.patchValue({ productId: this.loansAccountTemplate.loanProductId, @@ -136,6 +163,44 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy { this.searchItem(); }); this.productData.next(this.productList.slice()); + this.loadActiveProducts(); + } + + /** + * A new account can only be opened on a product that is active today, and the picker can be narrowed by category + * and type. Editing an existing account keeps every product, since its own product may have closed since. + */ + private loadActiveProducts(): void { + if (this.loanId) { + return; + } + this.catalog.activeProducts().subscribe((products) => { + this.activeProductIds = products.length > 0 ? new Set(products.map((product) => Number(product.id))) : null; + this.applyProductFilters(); + }); + } + + onCategoryChange(): void { + // A type belongs to one category, so a type from another category cannot stay selected. + if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) { + this.typeFilter = ''; + } + this.applyProductFilters(); + } + + /** Rebuilds the product list from the template: active only, then category and type, then the search text. */ + applyProductFilters(): void { + this.productList = this.allProducts.filter((product: any) => { + if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) { + return false; + } + const ext = this.catalog.classification(product.id); + if (this.categoryFilter && ext['productTypeGroup'] !== this.categoryFilter) { + return false; + } + return !this.typeFilter || ext['productTypeSubGroup'] === this.typeFilter; + }); + this.searchItem(); } ngOnDestroy(): void { diff --git a/src/app/loans/services/loan-product-catalog.service.ts b/src/app/loans/services/loan-product-catalog.service.ts new file mode 100644 index 000000000..907847faf --- /dev/null +++ b/src/app/loans/services/loan-product-catalog.service.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 { Injectable, inject } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; +import { ProductsService } from 'app/products/products.service'; + +/** + * Read-only view of the loan product catalogue for the loan account screens: which products are active today and + * what category and type each one is. Reads the normal product list once; the category and type of each product + * come from the classification the list carries (`advanceExtensions`). + */ +@Injectable({ + providedIn: 'root' +}) +export class LoanProductCatalogService { + private productsService = inject(ProductsService); + + private byId = new Map(); + + /** + * The products that are active today. Also remembers every product it saw, so `get` and `classification` can + * answer for them afterwards. An empty list on failure, so the picker never ends up with nothing to choose. + */ + activeProducts(): Observable { + return this.productsService.getLoanProducts().pipe( + tap((products: any[]) => (products ?? []).forEach((product) => this.byId.set(Number(product.id), product))), + map((products: any[]) => (products ?? []).filter((product) => product?.status === 'loanProduct.active')), + catchError(() => of([])) + ); + } + + /** A product seen by `activeProducts`, or undefined before it ran or for an unknown id. */ + get(productId: any): any { + return this.byId.get(Number(productId)); + } + + /** The product's category and type (`productTypeGroup`, `productTypeSubGroup`, ...), empty when it has none. */ + classification(productId: any): Record { + return this.get(productId)?.advanceExtensions ?? {}; + } +} diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html index 63e0e807a..eb165f58c 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -52,7 +52,9 @@ } } @else { Not set - {{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute + {{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute } } @@ -230,7 +232,7 @@ - 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 index 244bca647..701b5667c 100644 --- 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 @@ -41,7 +41,12 @@ export class CibilAdjustmentComponent implements OnInit { const idParam = this.route.snapshot.queryParamMap.get('id'); const id = idParam ? Number(idParam) : null; this.service.list().subscribe((all) => { - this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; this.active = this.service.getActive(); }); } 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 index 8db7e5e86..35c9f7843 100644 --- 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 @@ -27,7 +27,7 @@
- +
@@ -55,6 +55,9 @@ {{ rows.length }} products × {{ slabs.length }} amount slabs · click any cell to edit · read-only view + · {{ unmappedCount() }} without GL — map every product's GL before submitting
+
+
Add product
+
+
+ + +
+
+ + +
+ + +
+
{{ addProductError }}
+
+
+
Add amount slab
+
+
+ + +
+
+ + +
+ + +
+
The new slab is added as the last column; fill in its rates for each product.
+
{{ addSlabError }}
+
@@ -82,6 +122,25 @@ 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 index 0756a126f..13cad796d 100644 --- 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 @@ -424,6 +424,22 @@ } } + &__gl-tag { + display: inline-block; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + } + &__history-foot { padding: 10px 20px; border-top: 1px solid #f1f5f9; 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 index 2f4265b80..2fc9f859b 100644 --- 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 @@ -38,9 +38,15 @@ export class LoanInterestRateRegisterComponent implements OnInit { 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; @@ -168,10 +174,11 @@ export class LoanInterestRateRegisterComponent implements OnInit { /** Scaffold the very first version — used only when the register has no versions at all. */ createFirstVersion(): void { - const draft = this.service.createFirstVersion(); - this.snack.open('Blank draft created — fill in rates and submit for approval', 'OK', { duration: 3000 }); - this.reload(); - this.edit(draft); + 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). */ 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 index 943cc6029..8f25e359f 100644 --- 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 @@ -21,9 +21,17 @@ 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 { 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 index a7262b2a9..8f258ebdf 100644 --- 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 @@ -48,121 +48,23 @@ - -
-
-
-
Rate chart — inline diff
-
Changed cells highlighted · unchanged cells show single value
-
- - Show only changed rows - -
- -
- -
No changes detected
-
- Draft is identical to the current Active register. Edit the Draft first to see diffs here. -
-
- -
-
{{ row.srNo }}{{ row.particulars }} +
+ {{ row.glAccountId == null ? 'GL not mapped' : glLabel(row) }} +
+
{{ canEdit ? 'Cancel' : 'Back to list' }} 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 index 78c8ccc5b..25e958ccc 100644 --- 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 @@ -70,6 +70,85 @@ } } + &__gl { + margin: 3px 0 0 30px; + } + + &__gl-tag { + display: inline-block; + font-size: 10px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border-radius: 999px; + padding: 1px 8px; + white-space: nowrap; + + &--unmapped { + color: #92400e; + background: #fef3c7; + } + + &--editable { + cursor: pointer; + + &:hover { + outline: 1px solid #94a3b8; + } + } + } + + &__gl-select { + display: block; + margin: 4px 0 0 30px; + max-width: 340px; + } + + &__gl-warn { + color: #b45309; + font-weight: 600; + } + + &__add-panel { + padding: 14px 18px; + border-bottom: 1px solid #f1f5f9; + background: #f8fafc; + } + + &__add-title { + font-size: 13px; + font-weight: 600; + color: #0f172a; + margin-bottom: 10px; + } + + &__add-fields { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + + .er__field { + min-width: 240px; + } + + .er__field--gl { + min-width: 320px; + } + } + + &__add-hint { + margin-top: 8px; + font-size: 12px; + color: #64748b; + } + + &__add-error { + margin-top: 8px; + font-size: 12px; + color: #b91c1c; + } + &__readonly-banner { display: flex; align-items: center; 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 index 329cdd8f7..1900d114c 100644 --- 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 @@ -6,7 +6,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -18,7 +19,8 @@ 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 { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { GlOption, RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model'; +import { GlPickerComponent } from '../gl-picker/gl-picker.component'; /** * Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10 @@ -39,10 +41,13 @@ import { RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate MatSelectModule, MatIconModule, MatDatepickerModule, - MatNativeDateModule + MatNativeDateModule, + GlPickerComponent ] }) -export class EditRegisterComponent implements OnInit { +export class EditRegisterComponent implements OnInit, OnDestroy { + private readonly snack = inject(MatSnackBar); + private readonly service = inject(RateRegisterService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -58,11 +63,42 @@ export class EditRegisterComponent implements OnInit { @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) => { - this.register = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft(); + if (!found) { + // e.g. a reload on an unsaved local draft: nothing to edit, go back to the list. + this.router.navigate(['/products/loan-interest-rate-register']); + return; + } + this.register = found; this.active = this.service.getActive(); this.slabs = this.register.slabs; this.rows = this.register.rows; @@ -173,41 +209,130 @@ export class EditRegisterComponent implements OnInit { this.editing = null; } - /** Append a new empty product row at the bottom of the grid. */ + glLabel(row: RateChartRow): string | null { + return this.service.glLabel(row.glAccountId); + } + + unmappedCount(): number { + return this.rows.filter((r) => r.glAccountId == null).length; + } + + /** Submit is blocked until every product has a GL. */ + get canSubmit(): boolean { + return this.unmappedCount() === 0; + } + + /** Opens the Add product panel. */ addProduct(): void { if (!this.canEdit) { return; } - const name = window.prompt('New product name', 'New product'); + this.addingSlab = false; + this.addingProduct = true; + this.newProductName = ''; + this.newProductGl = null; + this.addProductError = ''; + } + + cancelAddProduct(): void { + this.addingProduct = false; + this.addProductError = ''; + } + + /** Append the new product row at the bottom of the grid. Name and GL are both mandatory. */ + confirmAddProduct(): void { + const name = this.newProductName.trim(); if (!name) { + 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.trim(), + particulars: name, + glAccountId: this.newProductGl, rates: Array(this.slabs.length).fill(null), isFormula: false }); + this.cancelAddProduct(); } - /** Append a new amount slab column; every existing row grows by one blank cell. */ + /** Toggle the inline GL selector on an existing row (Draft only). */ + editGl(rowIdx: number): void { + if (this.canEdit) { + this.glEditRow = this.glEditRow === rowIdx ? null : rowIdx; + } + } + + /** "Add slab" panel state. A slab is an amount band; at least a label or an upper limit is needed. */ + addingSlab = false; + newSlabLabel = ''; + newSlabAmount: number | null = null; + addSlabError = ''; + + /** Opens the Add slab panel. */ addSlab(): void { if (!this.canEdit) { return; } - const label = window.prompt('Slab label (e.g. "Upto 50,00,000" or "> 50,00,000")', 'New slab'); + this.addingProduct = false; + this.addingSlab = true; + this.newSlabLabel = ''; + this.newSlabAmount = null; + this.addSlabError = ''; + } + + cancelAddSlab(): void { + this.addingSlab = false; + this.addSlabError = ''; + } + + /** Label that is filled in for a slab when only the upper limit is given, e.g. "Upto 50,00,000". */ + suggestedSlabLabel(): string { + const amount = this.newSlabAmount; + return amount != null && Number.isFinite(amount) && amount > 0 ? `Upto ${amount.toLocaleString('en-IN')}` : ''; + } + + /** Append the new amount slab as the last column; every existing row grows by one blank cell. */ + confirmAddSlab(): void { + const amount = this.newSlabAmount; + const hasAmount = amount != null && String(amount) !== ''; + if (hasAmount && (!Number.isFinite(amount) || (amount as number) <= 0)) { + this.addSlabError = 'The upper limit must be a number greater than 0.'; + return; + } + const label = this.newSlabLabel.trim() || this.suggestedSlabLabel(); if (!label) { + 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; } - const amountText = window.prompt('Amount upper bound in ₹ (leave blank for no cap)', ''); - const amountUpto = amountText && amountText.trim() ? Number(amountText) : null; this.slabs.push({ key: `s_${Date.now()}`, - label: label.trim(), - amountUpto: amountUpto !== null && !Number.isFinite(amountUpto) ? null : amountUpto + label, + amountUpto: hasAmount ? (amount as number) : null }); this.rows.forEach((r) => r.rates.push(null)); + this.cancelAddSlab(); } editCibil(): void { 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 index ab378875c..cf9e44f33 100644 --- 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 @@ -54,9 +54,8 @@
No rate register versions yet
- Create the first version to publish rates for all loan products. The template comes pre-filled from the bank's - approved Excel chart (34 products × 10 amount slabs + 5 CIBIL bands) — you can edit everything before - submitting for approval. + 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.
{{ row.srNo }}{{ row.particulars }} + {{ + row.glAccountId == null ? 'GL not mapped' : glLabel(row.glAccountId) + }} {{ display(cell) }}
- - - - - - - - - - - - -
Particulars{{ s.label }}
- {{ row.srNo }}{{ row.particulars }} - - - {{ display(baselineCell(row.srNo, j)) }} - → - {{ display(draftCell(row.srNo, j)) }} - - {{ display(draftCell(row.srNo, j)) }} -
-
- - - -
-
-
-
CIBIL slabs — inline diff
-
Changed fields shown as OLD → NEW · unchanged fields render once
-
-
- - - - - - - - - - - - - - - - - - - -
#Score fromScore toTierAdjustment
{{ 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-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts index 50c14db3b..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 @@ -24,6 +24,11 @@ import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-p import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component'; import { 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'; @@ -63,12 +68,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; LoanProductPenalNpaStepComponent, LoanProductRenewalClosureStepComponent, LoanProductChequeBookStepComponent, - LoanProductSettingsStepComponent, - LoanProductInterestRefundStepComponent, - LoanProductPaymentStrategyStepComponent, - LoanProductTermsStepComponent, + 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 ] @@ -97,6 +108,18 @@ export class CreateLoanProductComponent implements OnInit { 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 }) @@ -179,6 +202,11 @@ export class CreateLoanProductComponent implements OnInit { 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 ?? ''; @@ -282,16 +310,26 @@ export class CreateLoanProductComponent implements OnInit { 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 = 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 { - tabs += 2; // settings, terms - if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income + } 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; } @@ -313,12 +351,28 @@ export class CreateLoanProductComponent implements OnInit { ); } - // Loans A/C: original Settings + Terms path. - const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; - if (this.isAdvancedPaymentStrategy) { - return base && loansValid && !!this.loanIncomeCapitalizationForm?.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 + ); } - return base && loansValid; + + // No product type chosen yet. + return false; + + // Old Loans A/C path (Settings + Terms) — commented out for now: + // const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid; + // if (this.isAdvancedPaymentStrategy) { + // return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid; + // } + // return base && loansValid; } get loanProduct() { @@ -328,7 +382,13 @@ export class CreateLoanProductComponent implements OnInit { return this.assembleAdvanceProduct(); } - // Loans A/C: original composition (Settings/Terms present). + // Loans A/C (EMI): builds its own payload from the Loans steps, mapped onto Fineract's core fields. + if (this.isLoanAccount) { + return this.assembleLoanProduct(); + } + return {}; + + /* OLD LOANS A/C PATH (Settings/Terms composition) — commented out for now. const loanProduct = { ...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductCurrencyStep.loanProductCurrency, @@ -364,6 +424,181 @@ 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; } /** @@ -413,7 +648,8 @@ export class CreateLoanProductComponent implements OnInit { // rate minInterestRatePerPeriod: minRate, - interestRatePerPeriod: minRate, + // A deposit-linked row ("1% + FD") has no plain minimum or maximum, so the register step supplies the default. + interestRatePerPeriod: num(intr.defaultRate) ?? minRate, maxInterestRatePerPeriod: num(intr.maxRate), interestRateFrequencyType: 3, // per year @@ -457,6 +693,7 @@ export class CreateLoanProductComponent implements OnInit { lienCertificatePrefix: sec.lienCertificatePrefix, printCertificateOnDisbursement: sec.printOnDisbursement, interestBasis: intr.interestBasis, + registerParticular: intr.registerParticular || undefined, spread: num(intr.spread), interestChargedOn: intr.interestChargedOn, interestPostingFrequency: intr.postingFrequency, 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 index 29d9a6b7e..062f2f8fe 100644 --- a/src/app/products/loan-products/loan-product-stepper/advance-steps.scss +++ b/src/app/products/loan-products/loan-product-stepper/advance-steps.scss @@ -172,3 +172,45 @@ $gray-900: #202124; 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 index d81f96fbf..2c616c118 100644 --- 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 @@ -11,36 +11,14 @@

Rate

+ Taken from the Loan Interest Rate Register
-
- - Interest Basis - - @for (opt of interestBasisOptions; track opt.id) { - {{ opt.label }} - } - - - @if (showSpread) { - - Spread over Deposit Rate (%) - - Advance rate = deposit rate + spread - - } @else { -
- } -
- - Minimum Rate (% p.a.) - - Minimum Rate is required - - - Maximum Rate (% p.a.) - - Maximum Rate is required - +
+
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 index 89dbb9956..4aa3d2dda 100644 --- 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 @@ -6,9 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, Input, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { 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, @@ -18,13 +19,23 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; selector: 'mifosx-loan-product-advance-interest-step', templateUrl: './loan-product-advance-interest-step.component.html', styleUrls: ['../advance-steps.scss'], - imports: [...STANDALONE_SHARED_IMPORTS] + 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' }, @@ -77,11 +88,12 @@ export class LoanProductAdvanceInterestStepComponent { Validators.max(20) ] ], - minRate: [ - '', - Validators.required - ], - maxRate: [ + // 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 ], @@ -95,6 +107,33 @@ export class LoanProductAdvanceInterestStepComponent { }); } + /** + * The register row decides the rate. A row of plain rates fixes the minimum and maximum; a deposit row + * ("1% + FD") is the deposit rate plus a spread, which has no plain minimum or maximum. With no row for the + * product the register particular stays empty, which keeps this step, and so the whole product, invalid until + * the product has been added to the register. + */ + onRegisterSelection(selection: RegisterRateSelection | null): void { + this.registerSelection = selection; + const form = this.loanProductAdvanceInterestForm; + if (selection) { + form.patchValue( + { + interestBasis: selection.isFormula ? 'deposit_spread' : 'fixed', + spread: selection.isFormula ? selection.minSpread : 0, + minRate: selection.minRate ?? '', + maxRate: selection.maxRate ?? '', + defaultRate: selection.defaultRate ?? '', + registerParticular: selection.particular + }, + { emitEvent: false } + ); + } else { + form.patchValue({ minRate: '', maxRate: '', defaultRate: '', registerParticular: '' }, { emitEvent: false }); + } + form.updateValueAndValidity({ emitEvent: false }); + } + get showSpread(): boolean { return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread'; } 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 57a5f6dc4..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 @@ -17,6 +17,7 @@ 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', @@ -110,35 +111,10 @@ export class LoanProductDetailsStepComponent implements OnInit { isExistingProduct = false; /** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */ - readonly productTypeGroups = [ - { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, - { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } - ]; + /** Category and type lists are shared with the product list and the loan account product picker. */ + readonly productTypeGroups = PRODUCT_TYPE_GROUPS; - /** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ - readonly productTypeSubGroups: Record = { - advances: [ - { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, - { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, - { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, - { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, - { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, - { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, - { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, - { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, - { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, - { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } - ], - loans: [ - { id: 'term', label: 'Term Loan', shortName: 'TL' }, - { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, - { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, - { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, - { id: 'education', label: 'Education Loan', shortName: 'EL' }, - { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, - { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } - ] - }; + readonly productTypeSubGroups = PRODUCT_SUB_TYPES; /** Regulatory Classification options (UI-only; RBI / bureau reporting). */ readonly loanProductCategoryOptions = [ 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 @@ + + +
+
+
+ +

Loan Amount

+
+
+ + Minimum Amount (₹) + + Minimum Amount is required + + + Default Amount (₹) + + + + Maximum Amount (₹) + + Maximum Amount is required + +
+
+
+ Each loan is sanctioned within these bounds. The amount is fixed at sanction. It is not a limit that can be + drawn and repaid again. +
+
+
+ +
+
+ +

Tenure

+
+
+ + Minimum Tenure (months) + + Minimum Tenure is required + + + Default Tenure (months) + + + + Maximum Tenure (months) + + Age at maturity cap comes from Borrower Eligibility below + Maximum Tenure is required + +
+
+ +
+
+ +

Borrower Eligibility

+
+
+ + Minimum Age (years) + + + + Maximum Age at Maturity (years) + + + + Minimum CIBIL Score + + + + Minimum Monthly Income (₹) + + + + Maximum FOIR / DBR (%) + + + + Maximum Loans per Customer + + +
+
+ +
+
+ +

Disbursement

+
+ + + 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 @@ + + +
+
+
+ +

Interest Type

+
+ + + Fixed + Same rate for the whole tenure + + + Floating + Linked to a benchmark; resets periodically + + + Hybrid + Fixed for a period, then floating + + +
+
+ +
+
+ +

Rate Source

+ Centralised — changes flow to all loans on this product +
+
+ +
+
+ + 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

+
+
+ + 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) { +
+
+ +

Floating Rate Reset

+ Shown only when the type is Floating or Hybrid +
+
+ + 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

+
+
+ 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 + + +
+
+ +
+
+ +

Pre-closure (Foreclosure)

+ Charge on outstanding principal +
+
+ + Individual · floating rate (%) + + Nil as per RBI + + + Individual · fixed rate (%) + + + + Non-individual / business (%) + + + + Takeover by another bank (%) + + +
+
+ + Lock-in Period (months) + + + + Interest on Pre-closure + + @for (opt of interestOnPreclosureOptions; track opt.id) { + {{ opt.label }} + } + + + + Foreclosure Quote Valid (days) + + +
+
+ +
+
+ +

Closure & Restructuring

+
+
+ + 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 @@ + + +
+
+
+ +

Repayment Type

+
+ + @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. +
+
+
+ +
+
+ +

Schedule

+
+
+ + Repaid Every + + Repaid Every is required + + + Frequency + + @for (opt of frequencyOptions; track opt.id) { + {{ opt.label }} + } + + + + Minimum Days to First Repayment + + + + EMI Due Day + + @for (opt of emiDueDayOptions; track opt.id) { + {{ opt.label }} + } + + + + If Due Date is a Holiday + + @for (opt of holidayRuleOptions; track opt.id) { + {{ opt.label }} + } + + + + Moratorium / Grace (months) + + + + Interest during Moratorium + + @for (opt of moratoriumInterestOptions; track opt.id) { + {{ opt.label }} + } + + +
+
+ Variable installments allowed +
+
+ +
+
+ +

Repayment Mode

+
+
+ + 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

+
+
+ + 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

+
+
+ + Security Type + + + + Security Mandatory + + @for (opt of securityMandatoryOptions; track opt.id) { + {{ opt.label }} + } + + + + Margin (%) + + Margin must be between 0 and 100 + + + Maximum LTV (%) + + = 100 − Margin + +
+
+ +
+
+ +

Collateral Rules by Security Type

+ Which security this product may accept +
+ + + + + + + + + + + + + @for (rule of collateralRules; track rule.security) { + + + + + + + + + } + +
SecurityAllowedMax LTV %ValuationInsuranceCharge / lien
{{ rule.security }} + {{ rule.allowed ? 'Yes' : 'No' }} + {{ rule.ltv }}{{ rule.valuation }}{{ rule.insurance }}{{ rule.charge }}
+
+ +
+
+ +

Guarantor & Co-applicant

+
+
+ + 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 }} + } + + +
+
+ +
+
+ +

Conditions

+
+
+
+ + 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/register-rate-view/register-rate-view.component.html b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html new file mode 100644 index 000000000..377661f29 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.html @@ -0,0 +1,97 @@ + + +@switch (state) { + @case ('loading') { +
Reading the Loan Interest Rate Register…
+ } + @case ('none') { +
+ There is no active Loan Interest Rate Register yet. + + A product takes its interest rate from the register, so it cannot be created until a register is created and + approved. + + Open the register → +
+ } + @case ('ready') { + @if (selectedRow) { +
+ + Interest comes from the active register {{ register?.name }}, effective {{ register?.effectiveFrom }}. + + Open register → +
+ } + + @if (!selectedRow) { +
+ This product has no interest rate in the Loan Interest Rate Register yet. + {{ blockReason }} + Add it in the register → +
+ } + + @if (selectedRow) { +
+ Register row + {{ selectedRow.srNo }}. {{ selectedRow.particulars }} + @if (glLabel(selectedRow); as gl) { + {{ gl }} + } + {{ matchNote }} +
+ +
+ + + + + + + + + @for (slab of slabRates; track slab.label) { + + + + + } + +
Amount slabRate
{{ slab.label }}{{ slab.text }}
+ + + + + + + + + + + @for (slab of cibilSlabs; track slab.from) { + + + + + + } + +
CIBIL scoreBandAdded to the rate
{{ slab.from }} – {{ slab.to }}{{ slab.tier }}{{ slab.adjustmentPct > 0 ? '+' + slab.adjustmentPct.toFixed(2) + '%' : 'No change' }}
+
+ @if (selectedRow.isFormula || slabRates[0]?.formula) { +
+ This row is the deposit's own rate plus a spread, so the final rate is worked out on each account from its + deposit. +
+ } + } + } +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss new file mode 100644 index 000000000..4347cf852 --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.scss @@ -0,0 +1,106 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +:host { + display: block; +} + +.rrv__source { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + a { + font-weight: 600; + white-space: nowrap; + } +} + +.rrv__block { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; + margin: 16px 24px 0; + padding: 12px 16px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; + line-height: 1.5; + + strong { + flex: 1 1 100%; + font-size: 14px; + } + + span { + flex: 1 1 360px; + } +} + +.rrv__block-btn { + font-weight: 600; + color: #991b1b; + white-space: nowrap; +} + +.rrv__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 14px; + padding: 14px 24px 4px; + font-size: 14px; + color: #1e293b; +} + +.rrv__row-label { + font-size: 12px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #5f6368; +} + +.rrv__row-gl { + padding: 1px 10px; + border-radius: 999px; + background: #e0f2fe; + color: #075985; + font-size: 12px; + font-weight: 600; +} + +.rrv__row-note { + font-size: 12px; + color: #5f6368; +} + +.rrv__tables { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 8px 24px 16px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.rrv__formula { + color: #075985; + font-weight: 600; +} + +.rrv__note { + padding: 0 24px 16px; + font-size: 13px; + color: #5f6368; +} diff --git a/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts new file mode 100644 index 000000000..44d9a35cc --- /dev/null +++ b/src/app/products/loan-products/loan-product-stepper/register-rate-view/register-rate-view.component.ts @@ -0,0 +1,220 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service'; +import { + CibilSlab, + RateCell, + RateChartRow, + RateRegister +} from 'app/products/loan-interest-rate-register/models/rate-register.model'; + +/** How the register row was found for the product. */ +export type RegisterMatch = 'gl' | 'type'; + +/** What the product takes from the register row, handed to the step that owns the rate fields. */ +export interface RegisterRateSelection { + particular: string; + srNo: number; + glAccountId: number | null; + /** True when the row holds formulas such as "1% + FD": the rate is the deposit's own rate plus a spread. */ + isFormula: boolean; + /** Lowest and highest plain rate in the row, the top one including the largest CIBIL adjustment. Null for a formula row. */ + minRate: number | null; + maxRate: number | null; + /** The rate the product is created with: the lowest plain rate, or the lowest spread for a formula row. */ + defaultRate: number | null; + /** Lowest and highest spread over the deposit rate; only for a formula row. */ + minSpread: number | null; + maxSpread: number | null; + matchedBy: RegisterMatch; + registerName: string; +} + +/** + * Product sub type id -> a word of the register particular that goes with it. This only covers the register's + * default rows. Any other product is found through its GL account, which every register row carries. + */ +const PARTICULAR_BY_SUB_TYPE: Record = { + fd: 'against fdr', + rd: 'against rd', + gold: 'gold loan', + cc: 'cash credit', + overdraft: 'overdraft', + kcc: 'agriculture', + housing: 'housing loan', + personal: 'personal loan (unsecured)', + vehicle: 'car loan (new)', + business: 'business term loan', + term: 'business term loan', + gold_emi: 'gold loan' +}; + +/** + * Shows the interest of a product as the Loan Interest Rate Register defines it: the rate of the product's own + * register row for each amount slab, and the CIBIL adjustment. Nothing is chosen or typed here. + * + * The row is found from the product's GL account first (the register maps each row to a GL), then from its sub + * type. The row found is reported through `selectionChange`, or null when there is none, which blocks the product. + */ +@Component({ + selector: 'mifosx-register-rate-view', + templateUrl: './register-rate-view.component.html', + styleUrls: [ + '../advance-steps.scss', + './register-rate-view.component.scss' + ], + imports: [RouterLink] +}) +export class RegisterRateViewComponent implements OnInit, OnChanges { + private registerService = inject(RateRegisterService); + + /** Sub type id picked on the Details step, e.g. `fd`. */ + @Input() productSubType = ''; + /** The product's GL account id from the Details step. */ + @Input() glAccountId: number | string | null = null; + @Output() selectionChange = new EventEmitter(); + + state: 'loading' | 'ready' | 'none' = 'loading'; + register?: RateRegister; + selectedSrNo: number | null = null; + matchedBy: RegisterMatch | null = null; + /** More than one register row fits the product and nothing tells them apart. */ + ambiguous = false; + + ngOnInit(): void { + this.registerService.getGlOptions().subscribe(); + this.registerService.list().subscribe(() => { + this.register = this.registerService.getActive(); + this.state = this.register && this.register.rows.length > 0 ? 'ready' : 'none'; + this.autoSelect(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + // The Details step can change after this one has opened, and a new type or GL means a new row. + if ((changes['productSubType'] || changes['glAccountId']) && this.state === 'ready') { + this.autoSelect(); + } + } + + get rows(): RateChartRow[] { + return this.register?.rows ?? []; + } + + get selectedRow(): RateChartRow | undefined { + return this.rows.find((row) => row.srNo === this.selectedSrNo); + } + + get cibilSlabs(): CibilSlab[] { + return this.register?.cibilSlabs ?? []; + } + + glLabel(row: RateChartRow): string | null { + return this.registerService.glLabel(row.glAccountId); + } + + /** The row's rate for every amount slab, ready to show. */ + get slabRates(): { label: string; text: string; formula: boolean; na: boolean }[] { + const row = this.selectedRow; + if (!row || !this.register) { + return []; + } + return this.register.slabs.map((slab, index) => { + const cell: RateCell = row.rates[index] ?? null; + return { + label: slab.label, + text: cell === null ? 'N.A.' : typeof cell === 'string' ? cell : `${cell.toFixed(2)}%`, + formula: typeof cell === 'string', + na: cell === null + }; + }); + } + + /** Why the product cannot go on: what the officer has to do in the register first. */ + get blockReason(): string { + const hasGl = this.glAccountId !== null && this.glAccountId !== ''; + if (!this.productSubType && !hasGl) { + return 'Choose the product type and the GL account on the Details step first.'; + } + const gl = hasGl ? this.registerService.glLabel(Number(this.glAccountId)) : null; + if (this.ambiguous) { + return `More than one register row uses the GL account${gl ? ` (${gl})` : ''}. Give each row its own GL account in the register so this product can be matched to one of them.`; + } + const forGl = gl ? ` with its GL account (${gl})` : ' with its GL account'; + return `Add this product${forGl} and its interest rates in the register and get the register approved. The product can be created only after that.`; + } + + /** How the row was found, shown beside it. */ + get matchNote(): string { + return this.matchedBy === 'gl' + ? `Found through the product's GL account (${this.glLabel(this.selectedRow!) ?? 'GL'}).` + : 'Found through the product type.'; + } + + private autoSelect(): void { + const rows = this.rows; + const word = PARTICULAR_BY_SUB_TYPE[this.productSubType]?.toLowerCase(); + const byType = word ? rows.filter((row) => row.particulars.toLowerCase().includes(word)) : []; + const gl = this.glAccountId === null || this.glAccountId === '' ? null : Number(this.glAccountId); + const byGl = gl === null ? [] : rows.filter((row) => row.glAccountId === gl); + + let chosen: RateChartRow | undefined; + let by: RegisterMatch | null = null; + this.ambiguous = false; + if (byGl.length === 1) { + chosen = byGl[0]; + by = 'gl'; + } else if (byGl.length > 1) { + // Several rows share the GL: the product type tells them apart, or nothing does. + chosen = byGl.find((row) => byType.includes(row)); + by = chosen ? 'type' : null; + this.ambiguous = !chosen; + } else if (byType.length === 1) { + chosen = byType[0]; + by = 'type'; + } else if (byType.length > 1) { + this.ambiguous = true; + } + this.selectedSrNo = chosen?.srNo ?? null; + this.matchedBy = by; + this.emit(); + } + + private emit(): void { + const row = this.selectedRow; + if (!row || !this.register) { + this.selectionChange.emit(null); + return; + } + const plain = row.rates.filter((cell): cell is number => typeof cell === 'number'); + const spreads = row.rates + .filter((cell): cell is string => typeof cell === 'string') + .map((text) => /(\d+(?:\.\d+)?)\s*%/.exec(text)?.[1]) + .filter((value): value is string => !!value) + .map(Number); + const topAdjustment = Math.max(0, ...this.cibilSlabs.map((slab) => Number(slab.adjustmentPct) || 0)); + const isFormula = spreads.length > 0 && plain.length === 0; + const minRate = plain.length > 0 ? Math.min(...plain) : null; + this.selectionChange.emit({ + particular: row.particulars, + srNo: row.srNo, + glAccountId: row.glAccountId ?? null, + isFormula, + minRate, + maxRate: plain.length > 0 ? Math.round((Math.max(...plain) + topAdjustment) * 100) / 100 : null, + defaultRate: isFormula ? Math.min(...spreads) : minRate, + minSpread: isFormula ? Math.min(...spreads) : null, + maxSpread: isFormula ? Math.max(...spreads) : null, + matchedBy: this.matchedBy ?? 'type', + registerName: this.register.name + }); + } +} diff --git a/src/app/products/loan-products/loan-product-types.ts b/src/app/products/loan-products/loan-product-types.ts new file mode 100644 index 000000000..f310cef62 --- /dev/null +++ b/src/app/products/loan-products/loan-product-types.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/. + */ + +/** + * The category ("product type") and sub type of a loan product. One list shared by the create-product wizard, the + * product list filters and the loan account product picker, so a type added here appears in all three. + */ + +export interface ProductTypeGroup { + id: string; + label: string; + hint: string; +} + +export interface ProductSubType { + id: string; + label: string; + /** The two-letter code the product's Short Name is pre-filled with when the sub type is picked. */ + shortName: string; +} + +export const PRODUCT_TYPE_GROUPS: ProductTypeGroup[] = [ + { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' }, + { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' } +]; + +export const PRODUCT_SUB_TYPES: Record = { + advances: [ + { id: 'fd', label: 'Loan against FD', shortName: 'FD' }, + { id: 'rd', label: 'Loan against RD', shortName: 'RD' }, + { id: 'dds', label: 'Loan against DDS', shortName: 'DS' }, + { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' }, + { id: 'gold', label: 'Loan against Gold', shortName: 'GD' }, + { id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' }, + { id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' }, + { id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' }, + { id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' }, + { id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' } + ], + loans: [ + { id: 'term', label: 'Term Loan', shortName: 'TL' }, + { id: 'personal', label: 'Personal Loan', shortName: 'PL' }, + { id: 'housing', label: 'Housing Loan', shortName: 'HL' }, + { id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' }, + { id: 'education', label: 'Education Loan', shortName: 'EL' }, + { id: 'business', label: 'Business / MSME Loan', shortName: 'BL' }, + { id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' } + ] +}; + +/** Label of a category id, or a dash for a product that carries none (created before categories existed). */ +export function productCategoryLabel(group: string | null | undefined): string { + return PRODUCT_TYPE_GROUPS.find((g) => g.id === group)?.label ?? '—'; +} + +/** Label of a sub type id within its category, or a dash when unknown. */ +export function productTypeLabel(group: string | null | undefined, subGroup: string | null | undefined): string { + return (PRODUCT_SUB_TYPES[group ?? ''] ?? []).find((s) => s.id === subGroup)?.label ?? '—'; +} diff --git a/src/app/products/loan-products/loan-products.component.scss b/src/app/products/loan-products/loan-products.component.scss index e15399058..48f1f175d 100644 --- a/src/app/products/loan-products/loan-products.component.scss +++ b/src/app/products/loan-products/loan-products.component.scss @@ -15,3 +15,49 @@ --pl-accent: #b45309; --pl-accent-l: #fef3c7; } + +/* Category / product type / active-only filters beside the search box. */ +.pl-filters { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.pl-filter { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 11px; + color: #64748b; + + select { + height: 34px; + min-width: 160px; + padding: 0 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + font-size: 13px; + color: #1e293b; + } + + &--check { + flex-direction: row; + align-items: center; + gap: 6px; + height: 34px; + font-size: 13px; + color: #1e293b; + } +} + +.pl-tag--advances { + background: #e0f2fe; + color: #075985; +} + +.pl-tag--loans { + background: #ede9fe; + color: #5b21b6; +} diff --git a/src/app/products/loan-products/loan-products.component.ts b/src/app/products/loan-products/loan-products.component.ts index f16a3bff1..f316a73ab 100644 --- a/src/app/products/loan-products/loan-products.component.ts +++ b/src/app/products/loan-products/loan-products.component.ts @@ -42,6 +42,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { StatusLookupPipe } from '../../pipes/status-lookup.pipe'; import { DateFormatPipe } from '../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types'; @Component({ selector: 'mifosx-loan-products', @@ -82,6 +83,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { displayedColumns: string[] = [ 'name', 'shortName', + 'category', + 'productType', 'closeDate', 'status', 'actions' @@ -90,6 +93,31 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { filteredCount = 0; + /** Category and type filters, fed by the classification each product carries. */ + readonly categories = PRODUCT_TYPE_GROUPS; + categoryFilter = ''; + typeFilter = ''; + activeOnly = false; + textFilter = ''; + + /** Types offered for the chosen category; every type when no category is chosen. */ + get typeOptions() { + return this.categoryFilter + ? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []) + : Object.values(PRODUCT_SUB_TYPES).flat(); + } + + categoryOf(product: any): string { + return productCategoryLabel(product?.advanceExtensions?.productTypeGroup); + } + + typeOf(product: any): string { + return productTypeLabel( + product?.advanceExtensions?.productTypeGroup, + product?.advanceExtensions?.productTypeSubGroup + ); + } + get totalCount(): number { return this.loanProductsData?.length || 0; } @@ -130,12 +158,52 @@ export class LoanProductsComponent implements OnInit, AfterViewInit { this.dataSource = new MatTableDataSource(this.loanProductsData); this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; + this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product); this.filteredCount = this.totalCount; this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length)); } applyFilter(filterValue: string) { - this.dataSource.filter = (filterValue || '').trim().toLowerCase(); + this.textFilter = (filterValue || '').trim().toLowerCase(); + this.refreshFilter(); + } + + onCategoryChange(): void { + // A type belongs to one category, so a type from another category cannot stay selected. + if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) { + this.typeFilter = ''; + } + this.refreshFilter(); + } + + /** The filter value itself is unused (the predicate reads the fields above); changing it re-runs the predicate. */ + refreshFilter(): void { + this.dataSource.filter = `${this.categoryFilter}|${this.typeFilter}|${this.activeOnly}|${this.textFilter}`; + } + + private matchesFilters(product: any): boolean { + const ext = product?.advanceExtensions ?? {}; + if (this.categoryFilter && ext.productTypeGroup !== this.categoryFilter) { + return false; + } + if (this.typeFilter && ext.productTypeSubGroup !== this.typeFilter) { + return false; + } + if (this.activeOnly && product?.status !== 'loanProduct.active') { + return false; + } + if (this.textFilter) { + const haystack = [ + product?.name, + product?.shortName, + this.categoryOf(product), + this.typeOf(product) + ] + .join(' ') + .toLowerCase(); + return haystack.includes(this.textFilter); + } + return true; } /** diff --git a/src/app/products/loan-products/loan-products.ts b/src/app/products/loan-products/loan-products.ts index 52e913db3..b097b9b43 100644 --- a/src/app/products/loan-products/loan-products.ts +++ b/src/app/products/loan-products/loan-products.ts @@ -50,10 +50,29 @@ export class LoanProducts { delete loanProduct.advancedAccountingRules; delete loanProduct.loanPeriod; - // The Advances payload is assembled explicitly in the create component and already carries only - // whitelisted params (core Fineract fields + satellite ADVANCE_PARAMETERS), so it is NOT stripped. - // For the Loans path, the Details/Security fields below are not backend params, so drop them. - if (loanProduct.productTypeGroup !== 'advances') { + // The Accounting step carries CBS-specific GL mappings that the backend does not accept yet. They are + // sent as '' when the officer leaves them blank, and the backend rejects the whole product for an + // unsupported parameter, so drop the ones that are empty. A mapping the officer actually selects is still + // sent and gets a clear "not supported" answer rather than being silently ignored. + [ + 'intReceivablesGlAccountId', + 'intPayableGlAccountId', + 'deafIntPayableGlAccountId', + 'intOverdueReserveAccountId', + 'undebitedInterestAccountId', + 'penalIntCrGlAccountId', + 'rdMaturedGlAccountId' + ].forEach((key) => { + if (loanProduct[key] === '' || loanProduct[key] === null || loanProduct[key] === undefined) { + delete loanProduct[key]; + } + }); + + // The Advances and Loans A/C (EMI) payloads are assembled explicitly in the create component and already + // carry only whitelisted params (core Fineract fields + satellite params), so they are NOT stripped. + // For any other product (the old Settings/Terms path), the Details/Security fields below are not backend + // params, so drop them. + if (loanProduct.productTypeGroup !== 'advances' && loanProduct.productTypeGroup !== 'loans') { delete loanProduct.productTypeGroup; delete loanProduct.productTypeSubGroup; delete loanProduct.glAccount; diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts index 9e3e84583..fb5046977 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/create-bucket/create-bucket.component.ts @@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; @@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateBucketComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private route = inject(ActivatedRoute); dialog = inject(MatDialog); @@ -174,6 +176,15 @@ export class CreateBucketComponent implements OnInit { }; this.productsService.createDelinquencyBucket(data).subscribe((response: any) => { + // Maker-checker: the request waits in the Checker Inbox, so there is no bucket to open yet. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency bucket request submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html index 1088a50ab..7a26b5f79 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.html @@ -7,6 +7,8 @@ -->
+ +

diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts index 00e11022e..24059b71f 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.ts @@ -24,6 +24,7 @@ import { } from '@angular/material/table'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, + DelinquencyPendingRequestsComponent, MatTable, MatSort, MatColumnDef, diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts index 7272967f4..716396d55 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/edit-bucket/edit-bucket.component.ts @@ -12,6 +12,7 @@ import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component'; import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; @@ -59,6 +60,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class EditBucketComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private router = inject(Router); private route = inject(ActivatedRoute); dialog = inject(MatDialog); @@ -191,7 +193,14 @@ export class EditBucketComponent implements OnInit { ranges: ranges }; - this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe(() => { + this.productsService.updateDelinquencyBucket(this.delinquencyBucketId, data).subscribe((response: any) => { + // Maker-checker: the change is applied only after a checker approves it. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency bucket change submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts index ae086e225..08abc1ad2 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-bucket/view-bucket/view-bucket.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -28,6 +29,7 @@ export class ViewBucketComponent { private router = inject(Router); private dialog = inject(MatDialog); private productsService = inject(ProductsService); + private alertService = inject(AlertService); /** Delinquency Bucket Data. */ delinquencyBucketData: any; @@ -47,7 +49,14 @@ export class ViewBucketComponent { }); dialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe(() => { + this.productsService.deleteDelinquencyBucket(this.delinquencyBucketData.id).subscribe((result: any) => { + // Maker-checker: the bucket is deleted only after a checker approves. + if (result?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency bucket delete request submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts index 8c0e5ce92..a58a558da 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/create-range/create-range.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class CreateRangeComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private route = inject(ActivatedRoute); private router = inject(Router); private settingsService = inject(SettingsService); @@ -71,6 +73,15 @@ export class CreateRangeComponent implements OnInit { locale }; this.productsService.createDelinquencyRange(data).subscribe((response: any) => { + // Maker-checker: the request waits in the Checker Inbox, so there is no range to open yet. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency range request submitted for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html index 4e56b3d84..3e2cdf8a5 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.html @@ -7,6 +7,8 @@ -->
+ +

diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts index 41d218989..706f2c846 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/delinquency-range.component.ts @@ -24,6 +24,7 @@ import { } from '@angular/material/table'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DelinquencyPendingRequestsComponent } from '../pending-requests/delinquency-pending-requests.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @Component({ @@ -33,6 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; imports: [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, + DelinquencyPendingRequestsComponent, MatTable, MatSort, MatColumnDef, diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts index a83a58af3..2aacfa487 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -24,6 +25,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class EditRangeComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private productsService = inject(ProductsService); + private alertService = inject(AlertService); private route = inject(ActivatedRoute); private router = inject(Router); private settingsService = inject(SettingsService); @@ -78,6 +80,15 @@ export class EditRangeComponent implements OnInit { locale }; this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => { + // Maker-checker: the change is applied only after a checker approves it. + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency range change submitted for approval.' + }); + this.router.navigate(['../../'], { relativeTo: this.route }); + return; + } this.router.navigate( [ '../../', diff --git a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts index 85a6a1e6c..810b80da9 100644 --- a/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts +++ b/src/app/products/manage-delinquency-buckets/delinquency-range/view-range/view-range.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ProductsService } from 'app/products/products.service'; +import { AlertService } from 'app/core/alert/alert.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -28,6 +29,7 @@ export class ViewRangeComponent { private router = inject(Router); private dialog = inject(MatDialog); private productsService = inject(ProductsService); + private alertService = inject(AlertService); /** Delinquency Range Data. */ delinquencyRangeData: any; @@ -44,7 +46,14 @@ export class ViewRangeComponent { }); dialogRef.afterClosed().subscribe((response: any) => { if (response.delete) { - this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe(() => { + this.productsService.deleteDelinquencyRange(this.delinquencyRangeData.id).subscribe((result: any) => { + // Maker-checker: the range is deleted only after a checker approves. + if (result?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Delinquency range delete request submitted for approval.' + }); + } this.router.navigate(['../'], { relativeTo: this.route }); }); } diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html new file mode 100644 index 000000000..537a2e6a2 --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.html @@ -0,0 +1,55 @@ + + +@if (rows.length > 0) { +
+
+ + {{ rows.length }} request{{ rows.length > 1 ? 's' : '' }} awaiting approval + These changes take effect only after a checker approves them. + Open Approvals → +
+ + + + + + + + + + + + @for (row of rows; track row.id) { + + + + + + + + } + +
ActionRecordWhat changesRequested byRequested on
+ {{ row.action }} + {{ row.subject }} + @if (row.changes.length > 0) { + @for (change of row.changes; track change.label) { +
+ {{ change.label }} + {{ change.oldValue }} + → + {{ change.newValue }} +
+ } + } @else { + {{ row.detail }} + } +
{{ row.maker }}{{ row.madeOn | date: 'dd MMM yyyy, hh:mm a' }}
+
+} diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss new file mode 100644 index 000000000..e913e9bad --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.scss @@ -0,0 +1,108 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.dpr { + margin-bottom: 16px; + border: 1px solid #fcd34d; + border-radius: 10px; + background: #fffbeb; + overflow: hidden; + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + font-size: 13px; + color: #78350f; + + i { + color: #d97706; + } + } + + &__sub { + color: #92400e; + } + + &__link { + margin-left: auto; + font-weight: 600; + color: #92400e; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + padding: 8px 16px; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #92400e; + border-top: 1px solid #fde68a; + } + + td { + padding: 8px 16px; + border-top: 1px solid #fde68a; + color: #1e293b; + } + } + + &__change { + display: flex; + align-items: baseline; + gap: 8px; + padding: 2px 0; + } + + &__field { + min-width: 90px; + font-size: 12px; + color: #92400e; + } + + &__old { + padding: 0 6px; + border-radius: 4px; + background: #fee2e2; + color: #991b1b; + text-decoration: line-through; + } + + &__arrow { + color: #92400e; + } + + &__new { + padding: 0 6px; + border-radius: 4px; + background: #dcfce7; + color: #166534; + font-weight: 600; + } + + &__chip { + display: inline-block; + padding: 1px 10px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 11px; + font-weight: 600; + } +} diff --git a/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts new file mode 100644 index 000000000..34d471f4e --- /dev/null +++ b/src/app/products/manage-delinquency-buckets/pending-requests/delinquency-pending-requests.component.ts @@ -0,0 +1,210 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, OnInit, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { Observable, forkJoin, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { ProductsService } from 'app/products/products.service'; + +/** One field that a pending request would change: what it is now, and what it would become. */ +interface PendingChange { + label: string; + oldValue: string; + newValue: string; +} + +interface PendingRow { + id: number; + action: string; + /** What the request is about, e.g. the range or bucket name. */ + subject: string; + /** UPDATE: the fields that move, old -> new. */ + changes: PendingChange[]; + /** CREATE / DELETE: a plain description of what is being added or removed. */ + detail: string; + maker: string; + madeOn: Date | null; +} + +/** + * Lists the Delinquency Range / Bucket requests that a maker has submitted and a checker has not yet + * approved. Without this the list pages look unchanged after a submit, so the maker cannot tell the + * request exists, what it would change, or where to approve it. + */ +@Component({ + selector: 'mifosx-delinquency-pending-requests', + templateUrl: './delinquency-pending-requests.component.html', + styleUrls: ['./delinquency-pending-requests.component.scss'], + imports: [ + DatePipe, + RouterLink + ] +}) +export class DelinquencyPendingRequestsComponent implements OnInit { + private productsService = inject(ProductsService); + + /** DELINQUENCY_RANGE or DELINQUENCY_BUCKET. */ + @Input({ required: true }) entityName: string; + + rows: PendingRow[] = []; + + ngOnInit(): void { + this.productsService + .getPendingMakerCheckerByEntity(this.entityName) + .pipe( + catchError(() => of([])), + switchMap((entries: any[]) => { + if (!entries || entries.length === 0) { + return of([] as PendingRow[]); + } + // The list call has no payload and no current values. The payload comes from the audit entry, + // and the current range or bucket from the normal read calls, so an UPDATE can show old -> new. + return forkJoin(entries.map((entry) => this.buildRow(entry))); + }) + ) + .subscribe((rows) => (this.rows = rows)); + } + + private buildRow(entry: any): Observable { + const isBucket = this.entityName === 'DELINQUENCY_BUCKET'; + const action = (entry.actionName ?? '').toUpperCase(); + const hasTarget = action !== 'CREATE' && entry.resourceId !== undefined && entry.resourceId !== null; + + const audit$ = this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe(catchError(() => of(null))); + const current$ = hasTarget + ? (isBucket + ? this.productsService.getDelinquencyBucket(entry.resourceId) + : this.productsService.getDelinquencyRange(entry.resourceId) + ).pipe(catchError(() => of(null))) + : of(null); + // Bucket ranges are stored and submitted as ids; the names come from the range list. + const ranges$ = isBucket ? this.productsService.getDelinquencyRanges().pipe(catchError(() => of([]))) : of([]); + + return forkJoin({ audit: audit$, current: current$, ranges: ranges$ }).pipe( + map(({ audit, current, ranges }) => { + let payload: any = null; + try { + payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : null; + } catch { + payload = null; + } + const rangesById = new Map( + (ranges ?? []).map((range: any) => [ + Number(range.id), + range + ]) + ); + const row: PendingRow = { + id: entry.id, + action, + subject: '', + changes: [], + detail: '', + maker: entry.maker, + madeOn: entry.madeOnDate ? new Date(Number(entry.madeOnDate) * 1000) : null + }; + if (isBucket) { + this.fillBucket(row, action, payload, current, rangesById); + } else { + this.fillRange(row, action, payload, current); + } + if (!row.subject) { + row.subject = entry.resourceId ? `#${entry.resourceId}` : '—'; + } + return row; + }) + ); + } + + private fillRange(row: PendingRow, action: string, payload: any, current: any): void { + const describe = (range: any) => + `${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)`; + if (action === 'CREATE' && payload) { + row.subject = payload.classification ?? ''; + row.detail = `New range: ${describe(payload)}`; + } else if (action === 'DELETE' && current) { + row.subject = current.classification ?? ''; + row.detail = `Range to be deleted: ${describe(current)}`; + } else if (action === 'UPDATE' && current) { + row.subject = current.classification ?? ''; + const fields: [string, string][] = [ + [ + 'classification', + 'Classification' + ], + [ + 'minimumAgeDays', + 'Days from' + ], + [ + 'maximumAgeDays', + 'Days till' + ] + ]; + row.changes = this.diff(fields, payload, current); + if (row.changes.length === 0) { + row.detail = 'No field value changes'; + } + } + } + + private fillBucket(row: PendingRow, action: string, payload: any, current: any, rangesById: Map): void { + const names = (ids: any[]) => + (ids ?? []) + .map((entry) => rangesById.get(Number(entry?.id ?? entry)) ?? { id: Number(entry?.id ?? entry) }) + .sort((a, b) => (a.minimumAgeDays ?? 0) - (b.minimumAgeDays ?? 0)) + .map((range) => range.classification ?? `Range #${range.id}`) + .join(', '); + if (action === 'CREATE' && payload) { + row.subject = payload.name ?? ''; + row.detail = `New bucket with ranges: ${names(payload.ranges) || '—'}`; + } else if (action === 'DELETE' && current) { + row.subject = current.name ?? ''; + row.detail = `Bucket to be deleted (ranges: ${names(current.ranges) || '—'})`; + } else if (action === 'UPDATE' && current && payload) { + row.subject = current.name ?? ''; + const before = { name: current.name, ranges: names(current.ranges) }; + const after = { name: payload.name ?? current.name, ranges: names(payload.ranges ?? current.ranges) }; + row.changes = this.diff( + [ + [ + 'name', + 'Name' + ], + [ + 'ranges', + 'Ranges' + ] + ], + after, + before + ); + if (row.changes.length === 0) { + row.detail = 'No field value changes'; + } + } + } + + /** The fields whose submitted value differs from today's. */ + private diff(fields: [string, string][], submitted: any, current: any): PendingChange[] { + return fields + .filter(([key]) => submitted && submitted[key] !== undefined && String(submitted[key]) !== String(current?.[key])) + .map( + ([ + key, + label + ]) => ({ + label, + oldValue: String(current?.[key] ?? '—'), + newValue: String(submitted[key]) + }) + ); + } +} diff --git a/src/app/products/products.service.ts b/src/app/products/products.service.ts index b4e4e63ff..28645524c 100644 --- a/src/app/products/products.service.ts +++ b/src/app/products/products.service.ts @@ -860,6 +860,34 @@ export class ProductsService { return this.http.get(`/loanproducts/baseinterestrates/${id}`, this.silentCtx()); } + /** Submitted Loan Rate Register versions still awaiting a checker (maker-checker entries with their payload). */ + getPendingLoanRateRegisters(): Observable { + const params = new HttpParams().set('entityName', 'LOANBASEINTERESTRATE'); + return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */ + getPendingMakerCheckerByEntity(entityName: string): Observable { + const params = new HttpParams().set('entityName', entityName); + return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */ + getLoanRateRegisterAuditDetail(id: number): Observable { + return this.http.get(`/audits/${id}`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** GL accounts a particular can be mapped to: asset (type 1) posting (detail) accounts that are not disabled. */ + getLoanRegisterGlAccounts(): Observable { + const params = new HttpParams().set('type', '1').set('usage', '1').set('disabled', 'false'); + return this.http.get('/glaccounts', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) }); + } + + /** Returns the master list of particulars (loan types) that make up the Loan Rate Register chart rows. */ + getLoanRateRegisterProducts(): Observable { + return this.http.get('/loanproducts/baseinterestrates/products', this.silentCtx()); + } + /** Submits a new Loan Rate Register version. If maker-checker is enabled, the response's * `rollbackTransaction` flag is true and the row sits in the Approvals tab until a checker approves. */ saveLoanBaseInterestRate(payload: any): Observable { diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss index b4cd0e3d4..938ff68c2 100644 --- a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss +++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss @@ -340,7 +340,6 @@ $red: #c5221f; } // ── Day by day ── -// // Outside the form's own sheet, because it is not part of what gets filed: the form reports two // closes, and this is the working behind them. .f1s-daily { diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts index 8d7727fd9..cf3c439e1 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -531,8 +531,12 @@ export abstract class ReserveReturnBase { // A half-typed year parses as a real date — "30-09-0026" is year 26 — and then fails the // ordering check below with a message about column order, which is not what went wrong. // Caught first so the filer is told about the year rather than sent looking at the dates. - const halfTyped = [this.statutoryFirstColumn, this.statutorySecondColumn, this.statutoryFirstBase, this.statutorySecondBase] - .filter((d) => d && Number(d.slice(0, 4)) < 1900); + const halfTyped = [ + this.statutoryFirstColumn, + this.statutorySecondColumn, + this.statutoryFirstBase, + this.statutorySecondBase + ].filter((d) => d && Number(d.slice(0, 4)) < 1900); if (halfTyped.length) { this.statutoryError = `The year in ${halfTyped[0]} looks incomplete — enter all four digits.`; return; @@ -1362,7 +1366,9 @@ export abstract class ReserveReturnBase { private addCimsGeneralInformation(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('General Information'); - sheet.columns = [{ width: 40 }, { width: 52 }]; + sheet.columns = [ + { width: 40 }, + { width: 52 }]; addBanner(sheet, 'General Information', 'B'); const start = sheet.rowCount + 1; @@ -1401,7 +1407,10 @@ export abstract class ReserveReturnBase { /** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */ private addCimsPartA(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part A'); - sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part A', this.cimsLastColumn()); sheet.addRow([]); this.addCimsEndDates(sheet, 'End Date'); @@ -1410,7 +1419,10 @@ export abstract class ReserveReturnBase { for (const line of this.statutory?.partA?.lines ?? []) { // The form's own indentation carries its numbering — (a), then (i) — so a flattened sheet // would lose which line sits under which head. - const row = sheet.addRow([`${' '.repeat(line.indent ?? 0)}${line.label}`, ...(line.values ?? [])]); + const row = sheet.addRow([ + `${' '.repeat(line.indent ?? 0)}${line.label}`, + ...(line.values ?? []) + ]); row.getCell(1).alignment = { wrapText: true }; if (line.heading || line.total) { row.font = { bold: true }; @@ -1429,7 +1441,11 @@ export abstract class ReserveReturnBase { */ private addCimsOthersSheet(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part A.II.c'); - sheet.columns = [{ width: 10 }, { width: 50 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 10 }, + { width: 50 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part A.II.c', this.columnLetter(this.cimsDates.length + 2)); sheet.addRow([]); @@ -1437,7 +1453,11 @@ export abstract class ReserveReturnBase { const last = this.cimsDates.length + 2; // "As at the close of Business on" heads BOTH dated columns as one caption, as the template has // it — written once and merged across them, not repeated over each. - const head = sheet.addRow(['Sr.No.', 'Particulars', 'As at the close of Business on']); + const head = sheet.addRow([ + 'Sr.No.', + 'Particulars', + 'As at the close of Business on' + ]); head.font = { bold: true }; head.alignment = { wrapText: true, horizontal: 'center' }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); @@ -1448,7 +1468,11 @@ export abstract class ReserveReturnBase { head.getCell(c).fill = solidFill(XL.headerGrey); } - const dates = sheet.addRow(['', '', ...this.cimsDates]); + const dates = sheet.addRow([ + '', + '', + ...this.cimsDates + ]); dates.font = { bold: true }; dates.alignment = { horizontal: 'center' }; for (let c = 3; c <= last; c++) { @@ -1457,12 +1481,20 @@ export abstract class ReserveReturnBase { // Sr.No. 1, hatched, as the template carries it: the break-up is written in by hand, so the row // has to be there to be written in. An empty sheet with only a Total says the bank reported // nothing; a hatched row says nobody has filled it in yet. - const entry = sheet.addRow([1, '', ...this.cimsDates.map((): null => null)]); + const entry = sheet.addRow([ + 1, + '', + ...this.cimsDates.map((): null => null) + ]); for (let c = 2; c <= last; c++) { entry.getCell(c).fill = hatchFill(); } - const total = sheet.addRow(['', 'Total', ...this.cimsDates.map(() => 0)]); + const total = sheet.addRow([ + '', + 'Total', + ...this.cimsDates.map(() => 0) + ]); total.font = { bold: true }; total.getCell(1).fill = solidFill(XL.headerGrey); total.getCell(2).alignment = { horizontal: 'center' }; @@ -1477,7 +1509,10 @@ export abstract class ReserveReturnBase { /** Parts B and C on one sheet, as the template has them. */ private addCimsPartBandC(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part B and C'); - sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part B and C', this.cimsLastColumn()); sheet.addRow([]); this.addCimsEndDates(sheet, 'End Date'); @@ -1524,7 +1559,10 @@ export abstract class ReserveReturnBase { */ private addCimsPartD(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Part D'); - sheet.columns = [{ width: 72 }, ...this.cimsDates.map(() => ({ width: 22 }))]; + sheet.columns = [ + { width: 72 }, + ...this.cimsDates.map(() => ({ width: 22 })) + ]; addBanner(sheet, 'Part D', this.cimsLastColumn()); sheet.addRow([]); this.addCimsEndDates(sheet, 'End Date'); @@ -1568,11 +1606,22 @@ export abstract class ReserveReturnBase { const perDate = 4; const dates = this.cimsDates; const sheet = wb.addWorksheet(name); - sheet.columns = [{ width: 38 }, ...dates.flatMap(() => Array(perDate).fill({ width: 19 }))]; + sheet.columns = [ + { width: 38 }, + ...dates.flatMap(() => Array(perDate).fill({ width: 19 })) + ]; addBanner(sheet, name, this.columnLetter(dates.length * perDate + 1)); sheet.addRow([]); - const dateRow = sheet.addRow(['', ...dates.flatMap((d) => [d, '', '', ''])]); + const dateRow = sheet.addRow([ + '', + ...dates.flatMap((d) => [ + d, + '', + '', + '' + ]) + ]); dateRow.font = { bold: true }; dates.forEach((_, i) => { const from = 2 + i * perDate; @@ -1584,12 +1633,25 @@ export abstract class ReserveReturnBase { const start = sheet.rowCount + 1; const head = sheet.addRow([ '', - ...dates.flatMap(() => ['Face Value', 'Book Value', 'Depreciation', 'Net Value for SLR purpose (2-3)']) + ...dates.flatMap(() => [ + 'Face Value', + 'Book Value', + 'Depreciation', + 'Net Value for SLR purpose (2-3)' + ]) ]); head.font = { bold: true }; head.alignment = { wrapText: true, horizontal: 'center' }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); - const numbers = sheet.addRow(['', ...dates.flatMap(() => [1, 2, 3, 4])]); + const numbers = sheet.addRow([ + '', + ...dates.flatMap(() => [ + 1, + 2, + 3, + 4 + ]) + ]); numbers.font = { bold: true }; numbers.alignment = { horizontal: 'center' }; @@ -1600,7 +1662,7 @@ export abstract class ReserveReturnBase { const movement = (label: string, carries: boolean, closing = false) => { const cells: (string | number)[] = [label]; for (let i = 0; i < dates.length; i++) { - const value = carries ? held[i] ?? 0 : 0; + const value = carries ? (held[i] ?? 0) : 0; cells.push(value, value, 0, value); } const row = sheet.addRow(cells); @@ -1654,8 +1716,7 @@ export abstract class ReserveReturnBase { { width: 21 }, { width: 21 }, { width: 19 }, - { width: 26 } - ]; + { width: 26 }]; addBanner( sheet, isCrr @@ -1671,7 +1732,12 @@ export abstract class ReserveReturnBase { const title = isCrr ? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]' : 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]'; - const band = sheet.addRow(['', '', '', title]); + const band = sheet.addRow([ + '', + '', + '', + title + ]); band.font = { bold: true }; band.alignment = { wrapText: true, horizontal: 'center' }; sheet.mergeCells(band.number, 4, band.number, 9); @@ -1694,7 +1760,11 @@ export abstract class ReserveReturnBase { head.alignment = { wrapText: true, horizontal: 'center' }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); // The first three columns are one heading spanning both rows, as the template has them. - for (const c of [1, 2, 3]) { + for (const c of [ + 1, + 2, + 3 + ]) { sheet.mergeCells(band.number, c, head.number, c); } @@ -1750,20 +1820,39 @@ export abstract class ReserveReturnBase { private addCimsSignatory(wb: ExcelJS.Workbook): void { const sheet = wb.addWorksheet('Signatory Information'); - sheet.columns = [{ width: 26 }, { width: 36 }, { width: 36 }]; + sheet.columns = [ + { width: 26 }, + { width: 36 }, + { width: 36 }]; addBanner(sheet, 'Signatory Information', 'C'); sheet.addRow([]); const start = sheet.rowCount + 1; - const head = sheet.addRow(['', 'Authorised Reporting Official', 'Countersigned By']); + const head = sheet.addRow([ + '', + 'Authorised Reporting Official', + 'Countersigned By' + ]); head.font = { bold: true }; head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); // Left to be signed, and hatched the way the template hatches it so the blank reads as "fill // this in" rather than as a field the return had nothing to say about. Who signs a filed return, // and on what date, is settled when it is filed — a name pre-filled here would put a signature // on a form nobody had agreed to sign. - for (const field of ['Name', 'Designation', 'e-mail ID', 'Tel. No. (O)', 'Tel. No. (R)', 'Place', 'Date']) { - const row = sheet.addRow([field, '', '']); + for (const field of [ + 'Name', + 'Designation', + 'e-mail ID', + 'Tel. No. (O)', + 'Tel. No. (R)', + 'Place', + 'Date' + ]) { + const row = sheet.addRow([ + field, + '', + '' + ]); row.getCell(1).font = { bold: true }; row.getCell(1).fill = solidFill(XL.keyCell); row.getCell(2).fill = hatchFill(); @@ -1779,7 +1868,10 @@ export abstract class ReserveReturnBase { * thing not shaded like the figures below it. */ private addCimsEndDates(sheet: ExcelJS.Worksheet, label: string): void { - const row = sheet.addRow([label, ...this.cimsDates]); + const row = sheet.addRow([ + label, + ...this.cimsDates + ]); row.font = { bold: true }; row.getCell(1).alignment = { horizontal: 'right' }; for (let c = 2; c <= this.cimsDates.length + 1; c++) { @@ -1826,7 +1918,10 @@ export abstract class ReserveReturnBase { * that part at all. */ private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void { - const row = sheet.addRow([label, ...(values ?? this.cimsDates.map((): null => null))]); + const row = sheet.addRow([ + label, + ...(values ?? this.cimsDates.map((): null => null)) + ]); row.getCell(1).alignment = { wrapText: true }; if (bold) { row.font = { bold: true }; @@ -1934,8 +2029,7 @@ export abstract class ReserveReturnBase { { width: 20 }, { width: 18 }, { width: 18 }, - { width: 20 } - ]; + { width: 20 }]; addBanner(sheet, `Day by day — ${daily.from} to ${daily.to}`, 'J'); // A day short is a breach. It goes above the table, where it is read before the figures rather @@ -1979,7 +2073,16 @@ export abstract class ReserveReturnBase { day.slrSurplus ]); // Column 3 is a date, so the figures run 2, then 4 onward. - for (const c of [2, 4, 5, 6, 7, 8, 9, 10]) { + for (const c of [ + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ]) { row.getCell(c).numFmt = '#,##0.00'; row.getCell(c).alignment = { horizontal: 'right' }; } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 0228d90b2..10555ff73 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -592,8 +592,8 @@ @if (statutoryFirstColumn && statutorySecondColumn) {

Made up to {{ statutorySecondColumn }}. The base dates above are the matching close one month earlier — change either - one if the bank files on a different base. + >. The base dates above are the matching close one month earlier — change either one if the bank files on + a different base.

} @else {

Enter both fortnight closing dates — the base dates fill in from them.

@@ -715,7 +715,7 @@ {{ amount(v) }} } - -
+
{{ headingTitle }} @@ -114,10 +114,30 @@
} + + @if (rateDiff) { +
+
Rate Chart Comparison
+ @if (changesBaseline) { +

{{ changesBaseline }}

+ } + +
+ } + @if (showChanges) {
Changes Requested ({{ changes.length }})
+ @if (changesBaseline) { +

{{ changesBaseline }}

+ } @if (changes.length === 0) {

This request does not change any value.

} @else { @@ -143,7 +163,7 @@ } - @if (!showChanges && createSections.length > 0) { + @if (!showChanges && !rateDiff && !loanRateLoading && createSections.length > 0) { @for (section of createSections; track section.title) {
{{ section.title }}
@@ -157,7 +177,7 @@ } - @if (!showChanges && createSections.length === 0 && displayJSONData) { + @if (!showChanges && !rateDiff && !loanRateLoading && createSections.length === 0 && displayJSONData) {
{{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }} diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss index 9bb961c1c..2dce81707 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss @@ -32,6 +32,11 @@ $bg-page: #f8fafc; border-radius: 10px; } + /* A wide payload (the Loan Base Interest Rate chart) fills the dialog the caller sized for it. */ + &--dialog#{&}--wide { + width: 100%; + } + &:not(&--dialog) { max-width: 900px; margin: 20px auto; diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts index ade448188..22a4a07b5 100644 --- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts +++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts @@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import * as _ from 'lodash'; +import { forkJoin } from 'rxjs'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; /** Custom Services */ @@ -31,6 +32,7 @@ import { groupCreateFields, humanizeKey } from '../command-diff.util'; +import { diffLoanRateRegister, isLoanBaseInterestRateCreate } from '../loan-rate-register-diff.util'; import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch'; /** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */ @@ -42,6 +44,22 @@ import { UsersService } from 'app/users/users.service'; import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; import { OrganizationService } from 'app/organization/organization.service'; +import { RateRegisterService } from 'app/products/loan-interest-rate-register/rate-register.service'; +import { RateRegisterDiffComponent } from 'app/products/loan-interest-rate-register/rate-register-diff/rate-register-diff.component'; +import { + CibilSlab, + RateChartRow, + SlabColumn +} from 'app/products/loan-interest-rate-register/models/rate-register.model'; + +/** Inputs of the shared rate-register comparison view (see RateRegisterDiffComponent). */ +interface RateDiffView { + slabs: SlabColumn[]; + draftRows: RateChartRow[]; + baselineRows: RateChartRow[]; + draftCibil: CibilSlab[]; + baselineCibil: CibilSlab[]; +} /** * One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of @@ -132,7 +150,8 @@ const ENTRY_LABEL_KEYS = [ ...STANDALONE_SHARED_IMPORTS, FaIconComponent, NgTemplateOutlet, - DateFormatPipe + DateFormatPipe, + RateRegisterDiffComponent ] }) export class ViewCheckerInboxComponent { @@ -150,6 +169,7 @@ export class ViewCheckerInboxComponent { private fixedDepositsService = inject(FixedDepositsService); private recurringDepositsService = inject(RecurringDepositsService); private organizationService = inject(OrganizationService); + private rateRegisterService = inject(RateRegisterService); /** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */ private get entityFetchServices(): EntityFetchServices { @@ -196,10 +216,22 @@ export class ViewCheckerInboxComponent { /** UPDATE review: the fields this command actually moves, as OLD -> NEW. */ changes: CommandChange[] = []; + /** Note naming the version a Loan Base Interest Rate request is compared against. */ + changesBaseline = ''; + /** Loan Base Interest Rate request: the chart-style comparison (same view as the Compare screen). */ + rateDiff: RateDiffView | null = null; /** True once the current entity was read and a real comparison is on screen. */ showChanges = false; /** True while the current entity is being fetched for comparison. */ comparing = false; + + /** A Loan Base Interest Rate request still waiting for its chart: hides the raw payload meanwhile. */ + get loanRateLoading(): boolean { + return ( + this.comparing && + isLoanBaseInterestRateCreate(this.checkerInboxDetail?.entityName, this.checkerInboxDetail?.actionName) + ); + } /** True when the screen is describing a record that is about to be deleted. */ deletingEntity = false; /** CREATE review: submitted data bucketed into readable sections. */ @@ -354,6 +386,8 @@ export class ViewCheckerInboxComponent { this.comparing = false; this.deletingEntity = false; this.createSections = []; + this.changesBaseline = ''; + this.rateDiff = null; // commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined) // throws — which as a route only broke the page, but in a dialog would leave an empty shell open. try { @@ -377,10 +411,16 @@ export class ViewCheckerInboxComponent { } else if (action === 'DELETE') { this.loadDeleteSummary(detail); } else if (action === 'CREATE') { + if (isLoanBaseInterestRateCreate(detail?.entityName, detail?.actionName)) { + this.loadLoanRateRegisterChanges(detail); + } this.createSections = this.buildCreateSections(this.jsondata); if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') { this.resolveGlAccountCreateLabels(detail, this.jsondata); } + if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') { + this.resolveDelinquencyBucketCreateRanges(detail); + } } } @@ -393,6 +433,10 @@ export class ViewCheckerInboxComponent { * entity showed before. */ private loadUpdateChanges(detail: any): void { + if ((detail?.entityName ?? '').toUpperCase() === 'DELINQUENCY_BUCKET') { + this.loadDelinquencyBucketChanges(detail); + return; + } const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices); if (!request$) { return; @@ -415,6 +459,131 @@ export class ViewCheckerInboxComponent { }); } + /** One line per range for a delinquency bucket, e.g. 'SMA-1 (31 - 60 days)', oldest range first. */ + private delinquencyRangeNames(ids: any[], rangesById: Map): string { + return (ids ?? []) + .map((entry) => rangesById.get(Number(entry?.id ?? entry)) ?? { id: Number(entry?.id ?? entry) }) + .sort((a, b) => (a.minimumAgeDays ?? 0) - (b.minimumAgeDays ?? 0)) + .map((range) => + range.classification + ? `${range.classification} (${range.minimumAgeDays} - ${range.maximumAgeDays ?? 'no limit'} days)` + : `Range #${range.id}` + ) + .join(', '); + } + + /** + * Delinquency bucket UPDATE: the payload lists range ids, which mean nothing to a checker, and the stored + * bucket lists range objects, so a plain field diff would show two unreadable lists. Both sides are turned + * into the same readable range names first, so only a real difference is reported as OLD -> NEW. + */ + private loadDelinquencyBucketChanges(detail: any): void { + this.comparing = true; + forkJoin({ + current: this.productsService.getDelinquencyBucket(detail?.resourceId), + ranges: this.productsService.getDelinquencyRanges() + }).subscribe({ + next: ({ current, ranges }: { current: any; ranges: any }) => { + if (!this.isCurrent(detail)) { + return; + } + this.comparing = false; + const rangesById = new Map( + (ranges ?? []).map((range: any) => [ + Number(range.id), + range + ]) + ); + const submitted = { + ...(this.jsondata ?? {}), + ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById) + }; + const existing = { ...(current ?? {}), ranges: this.delinquencyRangeNames(current?.ranges, rangesById) }; + this.changes = diffCommandAgainstEntity(submitted, existing); + this.showChanges = true; + }, + error: () => { + this.comparing = false; + } + }); + } + + /** Delinquency bucket CREATE: show the chosen ranges by name instead of their ids. */ + private resolveDelinquencyBucketCreateRanges(detail: any): void { + this.productsService.getDelinquencyRanges().subscribe({ + next: (ranges: any) => { + if (!this.isCurrent(detail)) { + return; + } + const rangesById = new Map( + (ranges ?? []).map((range: any) => [ + Number(range.id), + range + ]) + ); + const resolved = { + ...(this.jsondata ?? {}), + ranges: this.delinquencyRangeNames(this.jsondata?.ranges, rangesById) + }; + this.createSections = this.buildCreateSections(resolved); + this.commandFields = this.buildCommandFields(resolved); + } + }); + } + + /** + * Loan Base Interest Rate: every submission is a CREATE of a whole new version (16 loan types x 10 + * slabs plus CIBIL bands), so the raw payload hides the handful of rates the maker actually moved. + * Compares it with the version in force today and shows only the differences as OLD -> NEW. With no + * active version (first ever submission) the grouped CREATE view stays on screen. + */ + private loadLoanRateRegisterChanges(detail: any): void { + this.comparing = true; + this.productsService.getActiveLoanBaseInterestRate().subscribe({ + next: (active: any) => { + if (!this.isCurrent(detail)) { + return; + } + this.comparing = false; + this.changes = diffLoanRateRegister(this.jsondata ?? {}, active ?? {}); + this.rateDiff = this.buildRateDiff(detail, active); + if (active) { + const effective = active.effectiveFromDate ? `, effective ${active.effectiveFromDate}` : ''; + this.changesBaseline = `Compared with active version: ${active.name ?? 'Version ' + active.id}${effective}`; + } else { + this.changesBaseline = + 'No active version exists yet - this is the first version, so every filled value is new.'; + } + // The chart comparison already shows every change, so the flat list is only the fallback for a + // payload the chart could not be built from. + this.showChanges = !this.rateDiff; + }, + error: () => { + this.comparing = false; + } + }); + } + + /** + * The chart-style view of a Loan Base Interest Rate request: the submitted version against the active + * one (or against a blank baseline when there is no active version yet). Null if the payload cannot be + * read, in which case only the change list is shown. + */ + private buildRateDiff(detail: any, active: any): RateDiffView | null { + const submitted = this.rateRegisterService.registerFromPendingEntry(detail); + if (!submitted) { + return null; + } + const base = active ? this.rateRegisterService.registerFromServerDto(active) : null; + return { + slabs: submitted.slabs, + draftRows: submitted.rows, + baselineRows: base ? base.rows : submitted.baselineRows, + draftCibil: submitted.cibilSlabs, + baselineCibil: base ? base.cibilSlabs : submitted.baselineCibilSlabs + }; + } + /** * DELETE: the payload is empty for every entity, so the command alone tells the checker nothing. * Reads the record instead and shows the few fields that identify it. diff --git a/src/app/tds-reports/tds-interest-export.spec.ts b/src/app/tds-reports/tds-interest-export.spec.ts index 0b4335614..dbb9cbc9b 100644 --- a/src/app/tds-reports/tds-interest-export.spec.ts +++ b/src/app/tds-reports/tds-interest-export.spec.ts @@ -17,7 +17,12 @@ import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute'; installTdsConfigFixture(); const FY = '2025-26'; -const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; +const Q = [ + '2025-06-30', + '2025-09-30', + '2025-12-31', + '2026-03-31' +]; const BOOK: TdsDeductee[] = [ { @@ -94,13 +99,21 @@ function build() { describe('TDS register workbook', () => { it('has the four sheets a reader expects, in order', () => { - expect(build().worksheets.map((w) => w.name)).toEqual(['Summary', 'Customers', 'Accounts', 'Form 121 given']); + expect(build().worksheets.map((w) => w.name)).toEqual([ + 'Summary', + 'Customers', + 'Accounts', + 'Form 121 given' + ]); }); it('lists one row per customer, with the figures as numbers and a live total', () => { const ws = build().getWorksheet('Customers')!; expect(ws.getRow(3).getCell(2).value).toBe('Customer name'); - const at = [4, 5].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; + const at = [ + 4, + 5 + ].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; const ramesh = ws.getRow(at); expect(ramesh.getCell(2).value).toBe('Ramesh'); expect(ramesh.getCell(7).value).toBe(60_000); @@ -115,8 +128,21 @@ describe('TDS register workbook', () => { it('lists every account behind the figures, saying which ones count', () => { const ws = build().getWorksheet('Accounts')!; - const byAccount = new Map([4, 5, 6].map((n) => [ws.getRow(n).getCell(4).value, ws.getRow(n)])); - expect([...byAccount.keys()].sort()).toEqual(['FD11', 'FD41', 'SB12']); + const byAccount = new Map( + [ + 4, + 5, + 6 + ].map((n) => [ + ws.getRow(n).getCell(4).value, + ws.getRow(n) + ]) + ); + expect([...byAccount.keys()].sort()).toEqual([ + 'FD11', + 'FD41', + 'SB12' + ]); expect(byAccount.get('SB12')!.getCell(6).value).toBe('No'); expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes'); expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date); diff --git a/src/app/tds-reports/tds-interest-export.ts b/src/app/tds-reports/tds-interest-export.ts index 2a55151fe..f4f3c657b 100644 --- a/src/app/tds-reports/tds-interest-export.ts +++ b/src/app/tds-reports/tds-interest-export.ts @@ -70,11 +70,12 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] }); ws.columns = [ { width: 46 }, - { width: 22 } - ]; + { width: 22 }]; title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2); ws.addRow([`Branch: ${input.scopeLabel}`]); - ws.addRow([`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`]); + ws.addRow([ + `Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}` + ]); ws.getRow(2).font = { color: { argb: 'FF555555' } }; ws.getRow(3).font = { color: { argb: 'FF555555' } }; ws.addRow([]); @@ -82,20 +83,67 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { const s = input.summary; section(ws, 'Key figures'); const figures: [string, number, string?][] = [ - ['Customers in this report', s.deductees], - [' of whom have an FD / RD being tracked', s.trackedCustomers], - ['FD / RD interest received this year', s.applicableInterest, MONEY], - ['Savings interest (shown only — no TDS)', s.exemptInterest, MONEY], - ['Customers over the TDS limit', s.crossed], - ['TDS due by the rules', s.totalTds, MONEY], - ['TDS actually deducted from accounts', s.actualTds, MONEY], - ['Difference (due − deducted) — should be 0', s.totalTds - s.actualTds, MONEY], - ['Customers with Form 121 / 15G / 15H', s.declarations], - ['TDS not deducted because of those forms', s.tdsSaved, MONEY], - ['Interest paid without TDS (report in the return)', s.paidWithoutDeduction, MONEY] + [ + 'Customers in this report', + s.deductees + ], + [ + ' of whom have an FD / RD being tracked', + s.trackedCustomers + ], + [ + 'FD / RD interest received this year', + s.applicableInterest, + MONEY + ], + [ + 'Savings interest (shown only — no TDS)', + s.exemptInterest, + MONEY + ], + [ + 'Customers over the TDS limit', + s.crossed + ], + [ + 'TDS due by the rules', + s.totalTds, + MONEY + ], + [ + 'TDS actually deducted from accounts', + s.actualTds, + MONEY + ], + [ + 'Difference (due − deducted) — should be 0', + s.totalTds - s.actualTds, + MONEY + ], + [ + 'Customers with Form 121 / 15G / 15H', + s.declarations + ], + [ + 'TDS not deducted because of those forms', + s.tdsSaved, + MONEY + ], + [ + 'Interest paid without TDS (report in the return)', + s.paidWithoutDeduction, + MONEY + ] ]; - for (const [label, value, fmt] of figures) { - const r = ws.addRow([label, value]); + for (const [ + label, + value, + fmt + ] of figures) { + const r = ws.addRow([ + label, + value + ]); r.getCell(2).numFmt = fmt ?? '#,##0'; r.getCell(2).alignment = { horizontal: 'right' }; boxRow(r, 2); @@ -110,13 +158,31 @@ function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { if (input.rules) { section(ws, 'Rules for this year'); const rules: [string, string][] = [ - ['TDS limit — below 60', rupees(input.rules.general)], - ['TDS limit — senior citizen (60+)', rupees(input.rules.senior)], - ['Rate with PAN', `${input.rules.rate}%`], - ['Rate without PAN', `${input.rules.rateNoPan}%`] + [ + 'TDS limit — below 60', + rupees(input.rules.general) + ], + [ + 'TDS limit — senior citizen (60+)', + rupees(input.rules.senior) + ], + [ + 'Rate with PAN', + `${input.rules.rate}%` + ], + [ + 'Rate without PAN', + `${input.rules.rateNoPan}%` + ] ]; - for (const [label, value] of rules) { - const r = ws.addRow([label, value]); + for (const [ + label, + value + ] of rules) { + const r = ws.addRow([ + label, + value + ]); r.getCell(2).alignment = { horizontal: 'right' }; boxRow(r, 2); } @@ -253,7 +319,12 @@ function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { { header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true }, { header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true } ]; - const ws = table(wb, 'Form 121 given', `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, cols); + const ws = table( + wb, + 'Form 121 given', + `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, + cols + ); input.declaredRows.forEach((r, i) => { const line = ws.addRow([ @@ -276,7 +347,10 @@ function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { } }); if (input.declaredRows.length === 0) { - const r = ws.addRow(['', 'No customer has a declaration on record for this year.']); + const r = ws.addRow([ + '', + 'No customer has a declaration on record for this year.' + ]); r.font = { italic: true, color: { argb: 'FF777777' } }; } else { totals(ws, cols, input.declaredRows.length); diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index d553115f6..70117543e 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -49,11 +49,12 @@ account is which, and why.
  • - Only the interest from fixed and recurring deposits that reaches the customer's savings account + Only the interest from + fixed and recurring deposits that reaches the customer's savings account is tracked — counted on the day it arrives. The savings account's own interest is shown for information but never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to - savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is not - taxed. + savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is + not taxed.
  • @if (rules) {
  • @@ -102,8 +103,11 @@
    ₹{{ summary.totalTds | number: '1.0-0' }}
    TDS due by the rules
  • -
    +
    ₹{{ summary.actualTds | number: '1.0-0' }}
    Actually deducted from accounts @@ -332,8 +336,11 @@ } @else if (row.schedule.totalTds || row.actualTds) { ₹{{ row.schedule.totalTds | number: '1.0-0' }}
    due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%
    -
    +
    ₹{{ row.actualTds | number: '1.0-0' }} deducted
    } @else if (row.tdsSaved) { @@ -499,8 +506,8 @@ } @else if (selectedTab === 'paying') { No customer needs TDS deducted for these filters. Check the "No TDS" tab. } @else if (selectedTab === 'declared') { - No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's - Form 121 tab. + No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's Form + 121 tab. } @else { No customers match these filters. } diff --git a/src/app/tds-reports/tds-interest-report.component.ts b/src/app/tds-reports/tds-interest-report.component.ts index 618066c16..ca781d191 100644 --- a/src/app/tds-reports/tds-interest-report.component.ts +++ b/src/app/tds-reports/tds-interest-report.component.ts @@ -226,7 +226,10 @@ export class TdsInterestReportComponent implements OnInit { if (row.hasDeclaration) { const beforeForm = Math.round(row.schedule.totalTds - row.actualTds); if (beforeForm > 0) { - return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' }; + return { + label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, + tone: 'warn' + }; } if (row.tdsSaved > 0) { return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' }; @@ -244,7 +247,10 @@ export class TdsInterestReportComponent implements OnInit { return { label: 'Over the limit — TDS deducted', tone: 'ok' }; } if (row.actualTds > 0) { - return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' }; + return { + label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, + tone: 'warn' + }; } if (row.hasDeclaration && row.crossed) { return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' }; diff --git a/src/app/tds-reports/tds-remittance-report.component.html b/src/app/tds-reports/tds-remittance-report.component.html index 48dd34e94..27c8cda5b 100644 --- a/src/app/tds-reports/tds-remittance-report.component.html +++ b/src/app/tds-reports/tds-remittance-report.component.html @@ -45,8 +45,8 @@
    - This report is for Head Office. TDS is paid to the Government by Head Office for the whole bank, - under one TAN. Your branch's customers and the TDS deducted from them are on + This report is for Head Office. TDS is paid to the Government by Head Office for the whole + bank, under one TAN. Your branch's customers and the TDS deducted from them are on Reports › Deposit Interest & TDS.
    @@ -123,7 +123,9 @@
    Reconciled. - ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ summary.totalDeposited | number: '1.0-0' }} + ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ + summary.totalDeposited | number: '1.0-0' + }} paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
    @@ -144,9 +146,10 @@
    - By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but - ₹{{ summary.totalDeducted | number: '1.0-0' }} was actually deducted from accounts. Open - Deposit Interest & TDS to see which customers differ. + By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but ₹{{ + summary.totalDeducted | number: '1.0-0' + }} + was actually deducted from accounts. Open Deposit Interest & TDS to see which customers differ.
    } @@ -198,12 +201,16 @@ @for (row of rows; track trackRow($index, row)) { - {{ quarterLabel[row.quarter] }} + + {{ quarterLabel[row.quarter] }} + {{ row.month + '-01' | date: 'MMM y' }}
    {{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}
    - ₹{{ row.deducted | number: '1.0-2' }} + + ₹{{ row.deducted | number: '1.0-2' }} + {{ row.dueDate | date: 'd MMM y' }} @for (c of row.challans; track c.id) { @@ -330,9 +337,9 @@ }
    - The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the - quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of - declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current + The "without deduction" line is not optional — interest paid gross against a declaration must be reported in + the quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement + of declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current specification before generating the file.
    diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts index d24ffc6be..ee29ee6b5 100644 --- a/src/app/tds-reports/tds-remittance-report.component.ts +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -153,7 +153,8 @@ export class TdsRemittanceReportComponent implements OnInit { }, error: (err: any) => { this.saving = false; - this.formError = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; + this.formError = + err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; } }); } @@ -172,7 +173,16 @@ export class TdsRemittanceReportComponent implements OnInit { } private blankForm(): NewTdsChallan { - return { fy: this.fy, month: '', amount: 0, challanNo: '', bsrCode: '', depositedOn: '', paidFromGlAccountId: 0, remarks: '' }; + return { + fy: this.fy, + month: '', + amount: 0, + challanNo: '', + bsrCode: '', + depositedOn: '', + paidFromGlAccountId: 0, + remarks: '' + }; } trackRow(_: number, row: TdsRemittanceRow): string { @@ -228,19 +238,58 @@ export class TdsRemittanceReportComponent implements OnInit { recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]); recon.addRow([]); const lines: [string, number | string][] = [ - ['Deductees', s.deductees], - ['Total interest paid on time deposits', s.totalInterestPaid], - [' — with tax deducted', s.interestWithDeduction], - [' — without deduction (declaration on record)', s.interestWithoutDeduction], - ['TDS at standard rate (PAN on record)', s.tdsAtStandardRate], - ['TDS at higher rate (no PAN)', s.tdsAtHigherRate], - ['TDS due by the rules', s.calculatedTds], - ['Total TDS actually deducted', s.totalDeducted], - ['Total deposited with Government', s.totalDeposited], - ['Still within due date', s.pendingDeposit], - ['Overdue', s.overdueAmount], - ['DIFFERENCE (must be zero)', s.difference], - ['Interest payable for late deposit', s.defaultInterest] + [ + 'Deductees', + s.deductees + ], + [ + 'Total interest paid on time deposits', + s.totalInterestPaid + ], + [ + ' — with tax deducted', + s.interestWithDeduction + ], + [ + ' — without deduction (declaration on record)', + s.interestWithoutDeduction + ], + [ + 'TDS at standard rate (PAN on record)', + s.tdsAtStandardRate + ], + [ + 'TDS at higher rate (no PAN)', + s.tdsAtHigherRate + ], + [ + 'TDS due by the rules', + s.calculatedTds + ], + [ + 'Total TDS actually deducted', + s.totalDeducted + ], + [ + 'Total deposited with Government', + s.totalDeposited + ], + [ + 'Still within due date', + s.pendingDeposit + ], + [ + 'Overdue', + s.overdueAmount + ], + [ + 'DIFFERENCE (must be zero)', + s.difference + ], + [ + 'Interest payable for late deposit', + s.defaultInterest + ] ]; for (const line of lines) { recon.addRow(line); diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts index ee0aea6e5..fd8fd919d 100644 --- a/src/app/tds-reports/tds-reports.compute.spec.ts +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -25,7 +25,12 @@ installTdsConfigFixture(); * shapes ("some row crossed") because nobody could know the numbers. */ const FY = '2025-26'; -const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; +const Q = [ + '2025-06-30', + '2025-09-30', + '2025-12-31', + '2026-03-31' +]; const fd = (id: number, amounts: number[], extra: object = {}) => ({ id, @@ -68,23 +73,70 @@ function person( const BOOK: TdsDeductee[] = [ // Over the limit in Q4, plus a savings account that must stay outside the figure. person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [ - { ...fd(11, [15_000, 15_000, 15_000, 15_000]), deductions: [{ date: Q[3], amount: 6_000 }] }, + { + ...fd( + 11, + [ + 15_000, + 15_000, + 15_000, + 15_000 + ] + ), + deductions: [{ date: Q[3], amount: 6_000 }] + }, sb(12, 1_200) ]), // Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little. person(2, 'Sunita', '1955-01-01', '', 'bhopal', [ - { ...fd(21, [30_000, 30_000, 30_000, 30_000]), deductions: [{ date: Q[3], amount: 20_000 }] } + { + ...fd( + 21, + [ + 30_000, + 30_000, + 30_000, + 30_000 + ] + ), + deductions: [{ date: Q[3], amount: 20_000 }] + } ]), // ₹6,000 short of the limit: the customer to ask for a declaration. - person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [fd(31, [22_000, 22_000])]), + person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [ + fd( + 31, + [ + 22_000, + 22_000 + ] + ) + ]), // Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross. - person(4, 'Kamala', '1979-07-07', 'ABCPK9012M', 'Head Office', [fd(41, [20_000, 20_000, 20_000, 20_000])], { - [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } - }), + person( + 4, + 'Kamala', + '1979-07-07', + 'ABCPK9012M', + 'Head Office', + [ + fd( + 41, + [ + 20_000, + 20_000, + 20_000, + 20_000 + ] + ) + ], + { + [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } + } + ), // A minor's deposit, already listed under the guardian by the platform. person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [ - { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' } - ]), + { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' }]), // Savings only, no PAN: never deducted, so never counted as a no-PAN problem. person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)]) ]; @@ -201,7 +253,10 @@ describe('the summary', () => { describe('filters', () => { it('narrows to a branch by office name, whatever its case, without re-aggregating', () => { const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' })); - expect(scoped.map((r) => r.deductee.name).sort()).toEqual(['Sunita', 'Vitthal']); + expect(scoped.map((r) => r.deductee.name).sort()).toEqual([ + 'Sunita', + 'Vitthal' + ]); expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000); }); @@ -211,7 +266,11 @@ describe('filters', () => { tdsInterestRows(filter({ crossedOnly: true })) .map((r) => r.deductee.name) .sort() - ).toEqual(['Kamala', 'Ramesh', 'Sunita']); + ).toEqual([ + 'Kamala', + 'Ramesh', + 'Sunita' + ]); }); it('searches on name, customer ID and PAN', () => { @@ -242,7 +301,12 @@ describe('remittance', () => { }); it('marks a month paid in full and on time', () => { - const [r] = tdsRemittanceRows(FY, [{ month: '2026-03', amount: 26_000, postings: 2 }], [challan({ amount: 26_000 })], '2026-06-01'); + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000 })], + '2026-06-01' + ); expect(r.quarter).toBe(4); expect(r.outstanding).toBe(0); expect(r.late).toBe(false); diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts index de0e0e568..547d0a933 100644 --- a/src/app/tds-reports/tds-reports.compute.ts +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -446,7 +446,12 @@ export function tdsRemittanceRows( asAt: string ): TdsRemittanceRow[] { const live = challans.filter((c) => !c.reversed && c.fy === fy); - const months = [...new Set([...deducted.map((d) => d.month), ...live.map((c) => c.month)])].sort(); + const months = [ + ...new Set([ + ...deducted.map((d) => d.month), + ...live.map((c) => c.month) + ]) + ].sort(); return months.map((month) => { const d = deducted.find((x) => x.month === month); @@ -533,9 +538,7 @@ export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], asAt: const higher = rows.filter((r) => !r.panOnRecord); const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0)); const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0)); - const pendingDeposit = round2( - remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0) - ); + const pendingDeposit = round2(remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0)); const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0)); return { diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts index 89d50c912..27b7730d4 100644 --- a/src/app/tds-reports/tds-reports.service.ts +++ b/src/app/tds-reports/tds-reports.service.ts @@ -120,7 +120,9 @@ export class TdsReportsService { } createChallan(challan: NewTdsChallan): Observable { - return this.http.post('/tds/challans', challan).pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + return this.http + .post('/tds/challans', challan) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); } reverseChallan(id: number): Observable {