From be35f3cbc36d5c04e268362900b192a3a5f9b3ca Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 24 Sep 2026 18:07:29 +0530 Subject: [PATCH] feat: update TDS config, RBI reserve returns, current products, and dormancy reports --- .../client-general-step.component.ts | 5 +- src/app/clients/clients-routing.module.ts | 2 + .../form121-tab/form121-tab.component.html | 5 +- .../form121-tab/form121-tab.component.ts | 10 +- src/app/core/http/cache.interceptor.ts | 14 +- src/app/core/shell/shell.component.html | 22 + .../current-account-view.component.html | 5 +- .../general-tab/general-tab.component.html | 224 +++----- .../dormancy-report.component.html | 15 +- .../dormancy-report.component.ts | 23 +- .../dormancy-report.compute.ts | 2 + .../form-15gh/form-15gh.component.ts | 16 +- .../real-form15gh-view.component.ts | 16 +- .../form-15gh/tds-15gh.compute.spec.ts | 3 + .../form-15gh/tds-15gh.compute.ts | 86 +-- .../form-15gh/tds-config.fixture.ts | 71 +++ .../form-15gh/tds-config.service.ts | 150 ++++++ .../letter-mgmt/letter-mgmt-routing.module.ts | 2 + .../organization-routing.module.ts | 26 + .../organization/organization.component.html | 58 +++ src/app/organization/organization.service.ts | 67 +++ .../oss1-gl-config.component.ts | 47 ++ .../oss2-gl-config.component.ts | 46 ++ .../rbi-reserve-config.component.html | 336 ++++++++++++ .../rbi-reserve-config.component.scss | 475 +++++++++++++++++ .../rbi-reserve-config.component.ts | 485 +++++++++++++++++ .../tds-config/tds-config.component.html | 216 ++++++++ .../tds-config/tds-config.component.scss | 34 ++ .../tds-config/tds-config.component.ts | 275 ++++++++++ .../create-current-product.component.html | 1 + ...rrent-product-accounting-step.component.ts | 65 +-- ...urrent-product-preview-step.component.html | 138 ++--- ...rrent-product-settings-step.component.html | 132 +++-- ...current-product-settings-step.component.ts | 159 +++++- .../current-product-terms-step.component.html | 110 ++-- .../current-product-terms-step.component.ts | 103 ++-- .../edit-current-product.component.html | 1 + ...current-product-general-tab.component.html | 80 ++- .../current-product-general-tab.component.ts | 18 +- .../rbi-circulars/oss-live/oss-return.base.ts | 426 +++++++++++++++ .../oss-live/oss-return.component.html | 200 +++++++ .../oss-live/oss1-live.component.ts | 106 ++++ .../oss-live/oss2-live.component.ts | 88 ++++ src/app/rbi-circulars/oss-return.scss | 89 ++++ .../rbi-circulars-routing.module.ts | 32 +- .../rbi-circulars/rbi-circulars.service.ts | 71 +++ .../reserve-return/form1-crr.component.ts | 41 ++ .../reserve-return/form9-slr.component.ts | 41 ++ .../reserve-return/reserve-return.base.ts | 474 +++++++++++++++++ .../reserve-return.component.html | 320 ++++++++++++ .../reserve-return.component.scss | 491 ++++++++++++++++++ src/app/reports/reports-routing.module.ts | 3 + .../gl-settings-dialog.component.ts | 6 +- src/app/sponsor-dd/sponsor-dd.service.ts | 3 +- .../deposit-requests.component.ts | 4 +- .../disbursement-requests.component.ts | 6 +- .../pending-client-edits.component.ts | 6 +- .../withdrawal-requests.component.ts | 4 +- .../tds-interest-report.component.html | 7 + .../tds-interest-report.component.ts | 28 +- .../tds-remittance-report.component.ts | 7 +- src/app/tds-reports/tds-reports-data.ts | 12 +- .../tds-reports/tds-reports.compute.spec.ts | 9 +- src/app/tds-reports/tds-reports.compute.ts | 4 +- src/app/tds-reports/tds-reports.service.ts | 14 +- src/environments/.env.ts | 4 +- 66 files changed, 5474 insertions(+), 565 deletions(-) create mode 100644 src/app/letter-mgmt/certificates/form-15gh/tds-config.fixture.ts create mode 100644 src/app/letter-mgmt/certificates/form-15gh/tds-config.service.ts create mode 100644 src/app/organization/rbi-reserve-config/oss1-gl-config.component.ts create mode 100644 src/app/organization/rbi-reserve-config/oss2-gl-config.component.ts create mode 100644 src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html create mode 100644 src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss create mode 100644 src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts create mode 100644 src/app/organization/tds-config/tds-config.component.html create mode 100644 src/app/organization/tds-config/tds-config.component.scss create mode 100644 src/app/organization/tds-config/tds-config.component.ts create mode 100644 src/app/rbi-circulars/oss-live/oss-return.base.ts create mode 100644 src/app/rbi-circulars/oss-live/oss-return.component.html create mode 100644 src/app/rbi-circulars/oss-live/oss1-live.component.ts create mode 100644 src/app/rbi-circulars/oss-live/oss2-live.component.ts create mode 100644 src/app/rbi-circulars/reserve-return/form1-crr.component.ts create mode 100644 src/app/rbi-circulars/reserve-return/form9-slr.component.ts create mode 100644 src/app/rbi-circulars/reserve-return/reserve-return.base.ts create mode 100644 src/app/rbi-circulars/reserve-return/reserve-return.component.html create mode 100644 src/app/rbi-circulars/reserve-return/reserve-return.component.scss diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index ed53470c4..db39bae63 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -643,8 +643,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.createClientForm.get('degreeQualificationId')?.setValue(''); } }); - // Fetch all constitution options from code ID 17 - this.clientService.getCodeValuesById(17).subscribe((options) => { + // Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty + // ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty. + this.clientService.getCodeValuesByName('Constitution').subscribe((options) => { if (options?.length) { this.constitutionOptions = options; // Sort master by value length descending so longer/more-specific keys match first diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index 22ee81703..cb7b21e06 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -61,6 +61,7 @@ import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component'; import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component'; import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component'; import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component'; +import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service'; import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component'; import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component'; import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard'; @@ -198,6 +199,7 @@ const routes: Routes = [ { path: 'form121', component: Form121TabComponent, + resolve: { tdsConfig: tdsConfigResolver }, data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false } }, { diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.html b/src/app/clients/clients-view/form121-tab/form121-tab.component.html index 1b75a798d..800f617bf 100644 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.html +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.html @@ -55,7 +55,10 @@
info_outline
- @if (!dateOfBirth) { + @if (configMissing) { + TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under + Organization › TDS Configuration. + } @else if (!dateOfBirth) { Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal Data tab. } @else { 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 e8813db15..ca9d1252d 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 @@ -153,6 +153,8 @@ export class Form121TabComponent implements OnInit, OnDestroy { schedule: TdsSchedule | null = null; actualTds = 0; tdsSaved = 0; + /** The selected year has no entry on Organization › TDS Configuration. */ + configMissing = false; /** Raw account inputs, kept so the year selector can recompute cheaply. */ private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = []; @@ -212,7 +214,8 @@ export class Form121TabComponent implements OnInit, OnDestroy { this.panWellFormed = isWellFormedPan(this.pan); this.declarationOnRecord = !!kyc?.form15Applicable; this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G'; - this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121'; + this.declarationForm = + this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121'; this.declarationFy = kyc?.form15FY ?? ''; this.acceptanceDateKnown = false; } @@ -333,6 +336,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { } const year = normaliseFy(this.fy); + this.configMissing = !FY_TAX_CONFIG[year]; // Per-account interest for the selected year, for the three lists. for (const a of this.accountInputs) { @@ -340,7 +344,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { } this.actualTds = this.actualTdsByFy.get(year) ?? 0; - if (!this.dateOfBirth) { + if (!this.dateOfBirth || this.configMissing) { return; } @@ -411,7 +415,7 @@ export class Form121TabComponent implements OnInit, OnDestroy { /** The form the payee would file for the selected year. */ get formLabel(): string { - if (!this.fy) { + if (!this.fy || this.configMissing) { return '—'; } const form = declarationFormFor(this.fy, this.dateOfBirth); diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts index b20947383..26e07e932 100644 --- a/src/app/core/http/cache.interceptor.ts +++ b/src/app/core/http/cache.interceptor.ts @@ -25,6 +25,18 @@ import { environment } from '../../../environments/environment'; export class CacheInterceptor implements HttpInterceptor { private forceUpdate = false; + /** + * Endpoints that compute a live figure from the current ledger/configuration and must never be + * served from cache. Caching them meant a report kept showing a stale response after its + * underlying GL mapping changed — the request was answered from cachedData and the backend was + * never hit again. Matched as a substring of the request URL. + */ + private static readonly NEVER_CACHE = ['/rbireservereturn', '/rbireserveconfig', '/tdsconfig', '/tds/deposit-interest']; + + private isCacheable(url: string): boolean { + return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment)); + } + // eslint-disable-next-line @angular-eslint/prefer-inject constructor(private httpCacheService: HttpCacheService) {} @@ -42,7 +54,7 @@ export class CacheInterceptor implements HttpInterceptor { } intercept(request: HttpRequest, next: HttpHandler): Observable> { - if (request.method !== 'GET') { + if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) { return next.handle(request); } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 7a55eb0dc..6021b9998 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -2071,6 +2071,28 @@ Members & Share Capital
+ +
CIMS / XBRL Returns
diff --git a/src/app/current-accounts/current-account-view/current-account-view.component.html b/src/app/current-accounts/current-account-view/current-account-view.component.html index 85a03caaf..21b7bed1c 100644 --- a/src/app/current-accounts/current-account-view/current-account-view.component.html +++ b/src/app/current-accounts/current-account-view/current-account-view.component.html @@ -8,7 +8,10 @@
- + + @if (currentAccountData?.dormancyAlert) { + + }
diff --git a/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html b/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html index 8839b59ca..3738be9ba 100644 --- a/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html +++ b/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html @@ -59,16 +59,7 @@ [{{ currentAccountData.currency.code }}]
-
- {{ 'labels.inputs.Nominal Interest Rate' | translate }} - - @if (currentAccountData.nominalAnnualInterestRate) { - {{ currentAccountData.nominalAnnualInterestRate | formatNumber }} % - } @else { - 0 % - } - -
+ } @else {
{{ 'labels.inputs.Field Officer' | translate }} @@ -102,17 +93,17 @@
} - @if (currentAccountData.summary.totalInterestEarned >= 0) { + @if (currentAccountData.summary.totalWithdrawals) {
- {{ 'labels.inputs.Total Interest Earned' | translate }} - + {{ 'labels.inputs.Total Withdrawls' | translate }} + {{ - currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2' + currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
} - @if (!currentAccountData.summary.totalDeposits && !(currentAccountData.summary.totalInterestEarned >= 0)) { + @if (!currentAccountData.summary.totalDeposits && !currentAccountData.summary.totalWithdrawals) {
No activity yet. @@ -132,16 +123,7 @@

{{ 'labels.heading.Account Summary' | translate }}

- @if (currentAccountData.summary.totalWithdrawals) { -
- {{ 'labels.inputs.Total Withdrawls' | translate }} - - {{ - currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2' - }} - -
- } + @if (currentAccountData.summary.totalWithdrawalFees) {
{{ 'labels.inputs.Withdrawals Fees' | translate }} @@ -160,89 +142,6 @@
} - @if (currentAccountData.summary.totalInterestEarned >= 0) { -
- {{ 'labels.inputs.Interest Earned' | translate }} - - {{ - currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2' - }} - -
- } - @if (currentAccountData.summary.totalInterestPosted) { -
- {{ 'labels.inputs.Interest Posted' | translate }} - - {{ - currentAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2' - }} - -
- } - @if (currentAccountData.summary.interestNotPosted >= 0) { -
- {{ 'labels.inputs.Interest Earned Not Posted' | translate }} - - {{ - currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2' - }} - -
- } - @if (currentAccountData.summary.totalOverdraftInterestDerived) { -
- {{ 'labels.inputs.Interest On Overdraft' | translate }} - - {{ - currentAccountData.summary.totalOverdraftInterestDerived - | currency: currency.code : 'symbol-narrow' : '1.2-2' - }} - -
- } - @if (currentAccountData.summary.interestNotPosted < 0) { -
- {{ 'labels.inputs.Overdraft Interest Not Posted' | translate }} - - {{ - currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2' - }} - -
- } - @if (currentAccountData.nominalAnnualInterestRate) { -
- {{ 'labels.inputs.Nominal Interest Rate' | translate }} - - {{ currentAccountData.nominalAnnualInterestRate | formatNumber }} % - -
- } -
- {{ 'labels.inputs.Interest Compounding Period' | translate }} - - {{ currentAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }} - -
-
- {{ 'labels.inputs.Interest Posting Period' | translate }} - - {{ currentAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }} - -
-
- {{ 'labels.inputs.Interest Calculated Using' | translate }} - - {{ currentAccountData.interestCalculationType.value | translateKey: 'catalogs' }} - -
-
- {{ 'labels.inputs.Days in Year' | translate }} - - {{ currentAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }} - -
@if (currentAccountData.withdrawalFee) {
{{ 'labels.inputs.Withdrawal Fee' | translate }} @@ -289,37 +188,6 @@
} - @if (currentAccountData.allowOverdraft) { -
- {{ 'labels.inputs.Overdraft Limit' | translate }} - - {{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }} - -
- } - @if (currentAccountData.allowOverdraft) { -
- - {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }} - - - {{ - currentAccountData.minOverdraftForInterestCalculation - | currency: currency.code : 'symbol-narrow' : '1.2-2' - }} - -
- } - @if (currentAccountData.minBalanceForInterestCalculation) { -
- - {{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }} - - - {{ currentAccountData.minBalanceForInterestCalculation | formatNumber }} - -
- } @if (currentAccountData.minRequiredBalance) {
{{ 'labels.inputs.Minimum Required Balance' | translate }} @@ -332,14 +200,6 @@ {{ currentAccountData.enforceMinRequiredBalance | yesNo }}
} - @if (currentAccountData.summary.lastInterestCalculationDate) { -
- {{ 'labels.inputs.Interest Recalculation Date' | translate }} - - {{ currentAccountData.summary.lastInterestCalculationDate | dateFormat }} - -
- } @if (currentAccountData.onHoldFunds) {
{{ 'labels.inputs.On Hold Funds' | translate }} @@ -350,14 +210,72 @@
} - @if (currentAccountData.withHoldTax) { -
- {{ 'labels.inputs.Withhold Tax Group' | translate }} - {{ currentAccountData.taxGroup.name }} -
- }
+ + + @if (currentAccountData.allowOverdraft) { +
+
+ +

Overdraft (OD)

+
+
+
+ Maximum OD Limit (per product) + + {{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ @if (currentAccountData.nominalAnnualInterestRateOverdraft) { +
+ OD Interest Rate + + {{ currentAccountData.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a. + +
+ } + @if (currentAccountData.minOverdraftForInterestCalculation) { +
+ Minimum OD for Interest + + {{ + currentAccountData.minOverdraftForInterestCalculation + | currency: currency.code : 'symbol-narrow' : '1.2-2' + }} + +
+ } +
+ OD Interest Posting + + {{ currentAccountData.interestPostingPeriodType?.value | translateKey: 'catalogs' }} + +
+
+ {{ 'labels.inputs.Days in Year' | translate }} + + {{ currentAccountData.interestCalculationDaysInYearType?.value | translateKey: 'catalogs' }} + +
+ @if (currentAccountData.validForPeriod) { +
+ Sanction Validity + {{ currentAccountData.validForPeriod }} +
+ } +
+ OD Interest Charged + + {{ + (currentAccountData.summary.totalOverdraftInterestDerived || 0) + | currency: currency.code : 'symbol-narrow' : '1.2-2' + }} + +
+
+
+ } } @else {
diff --git a/src/app/dormancy-report/dormancy-report.component.html b/src/app/dormancy-report/dormancy-report.component.html index 52db5ef8d..c316ef8a1 100644 --- a/src/app/dormancy-report/dormancy-report.component.html +++ b/src/app/dormancy-report/dormancy-report.component.html @@ -181,15 +181,15 @@ - @for (r of visibleRows; track r.accountId) { - + @for (r of visibleRows; track r.accountType + '-' + r.accountId) { + @if (tab === 'ESCHEAT') { @@ -203,7 +203,12 @@ {{ branchName(r.branchCode) }} - {{ r.productName }} + + {{ r.productName }} + @if (r.accountType === 'CURRENT') { +
Current Account
+ } + {{ r.balance | number: '1.0-0' }} diff --git a/src/app/dormancy-report/dormancy-report.component.ts b/src/app/dormancy-report/dormancy-report.component.ts index 39c794881..09b5325fd 100644 --- a/src/app/dormancy-report/dormancy-report.component.ts +++ b/src/app/dormancy-report/dormancy-report.component.ts @@ -147,8 +147,24 @@ export class DormancyReportComponent implements OnInit { remitSaving = false; remitError = ''; + /** + * Only savings accounts can be put into a DEAF remittance: remittances are recorded against a savings account, so + * the selection (keyed by account id) holds savings ids alone and a current account with the same id cannot be + * mistaken for one. + */ + isRemittable(row: DormancyRow): boolean { + return row.accountType !== 'CURRENT' && row.remittance !== 'REMITTED'; + } + + isSelected(row: DormancyRow): boolean { + return row.accountType !== 'CURRENT' && this.selected.has(row.accountId); + } + toggleSelect(row: DormancyRow, event: Event): void { event.stopPropagation(); + if (!this.isRemittable(row)) { + return; + } if (this.selected.has(row.accountId)) { this.selected.delete(row.accountId); } else { @@ -158,7 +174,7 @@ export class DormancyReportComponent implements OnInit { /** Ticks every unremitted row on display, or clears them if all are already ticked. */ toggleSelectAll(): void { - const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); + const selectable = this.visibleRows.filter((r) => this.isRemittable(r)); if (selectable.every((r) => this.selected.has(r.accountId))) { selectable.forEach((r) => this.selected.delete(r.accountId)); } else { @@ -177,12 +193,12 @@ export class DormancyReportComponent implements OnInit { } get allSelected(): boolean { - const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); + const selectable = this.visibleRows.filter((r) => this.isRemittable(r)); return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId)); } get selectedTotal(): number { - return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0); + return this.rows.filter((r) => this.isSelected(r)).reduce((s, r) => s + r.balance, 0); } openRemit(): void { @@ -316,6 +332,7 @@ export class DormancyReportComponent implements OnInit { const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; return { + accountType: a?.accountType || 'SAVINGS', accountId: a?.accountId, accountNo: a?.accountNo || '—', clientId: a?.clientId, diff --git a/src/app/dormancy-report/dormancy-report.compute.ts b/src/app/dormancy-report/dormancy-report.compute.ts index bd491edb2..5867c8bd8 100644 --- a/src/app/dormancy-report/dormancy-report.compute.ts +++ b/src/app/dormancy-report/dormancy-report.compute.ts @@ -80,6 +80,8 @@ export const DORMANCY_STAGES: Record = { export type DeafRemittanceState = 'PENDING' | 'REMITTED'; export interface DormancyRow { + /** SAVINGS or CURRENT. Ids are only unique within one kind, so rows are keyed by both. */ + accountType: string; accountId: number; accountNo: string; clientId: number; diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts index 9ab9b9b5d..51336d7c2 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts @@ -34,6 +34,7 @@ import { ageInFy, buildSchedule, checkEligibility, + configuredFys, declarationFormFor, formKindFor, isSeniorInFy, @@ -134,7 +135,10 @@ export class Form15ghComponent implements OnInit { // Derived once. The template reads these many times per change-detection // pass, and re-running the schedules on every read is what made the other // register screens stall. - this.rows = seedForm15gh().map((record) => this.buildRow(record)); + // Only years that are configured can be computed; the engine throws on any other. + this.rows = seedForm15gh() + .filter((record) => !!FY_TAX_CONFIG[normaliseFy(record.fy)]) + .map((record) => this.buildRow(record)); } private buildRow(record: Form15ghRecord): Form15ghRow { @@ -219,13 +223,13 @@ export class Form15ghComponent implements OnInit { * than written into the template. */ get statute(): { form: string; section: string; statement: string; frequency: string } { - const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27'); + const fy = normaliseFy(this.rows[0]?.record.fy ?? configuredFys()[0] ?? ''); const cfg = FY_TAX_CONFIG[fy]; return { - form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', - section: cfg.interestTdsSection, - statement: cfg.tdsStatementForm, - frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' + form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', + section: cfg?.interestTdsSection ?? '—', + statement: cfg?.tdsStatementForm ?? '—', + frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' }; } diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts index dfac843d7..fca67d7bc 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts @@ -41,6 +41,7 @@ import { buildSchedule, checkEligibility, collectTdsCredits, + configuredFys, declarationFormFor, formKindFor, isSeniorInFy, @@ -115,18 +116,15 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { /** Which row's quarter-wise breakdown is open. */ expandedId: string | null = null; - selectedFy = 'FY 2026-27'; + /** Every year configured on Organization › TDS Configuration, loaded by the route resolver. */ + readonly availableFys = configuredFys().map((fy) => `FY ${fy}`); + selectedFy = this.availableFys[0] ?? ''; // Per-client caches private clientDataCache = new Map>(); private clientKycCache = new Map>(); private clientAccountsCache = new Map>(); - readonly availableFys = [ - 'FY 2026-27', - 'FY 2025-26' - ]; - ngOnInit(): void { this.loadRealData(); } @@ -137,6 +135,12 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy { } loadRealData(): void { + // No configured year means no rates to compute with; report it rather than throw mid-build. + if (!this.selectedFy) { + this.loading = false; + this.loadError = true; + return; + } this.loading = true; this.loadError = false; this.clientDataCache.clear(); diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts index 636979647..502e3f5b0 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts @@ -30,6 +30,9 @@ import { tdsDeducteeOf, tdsModeFor } from './tds-15gh.compute'; +import { installTdsConfigFixture } from './tds-config.fixture'; + +installTdsConfigFixture(); const FY = 'FY 2026-27'; diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts index 9cba8c989..870e75dc7 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts @@ -129,73 +129,37 @@ export interface FyTaxConfig { } /** - * Statutory figures for the years this book covers, on the new tax regime. + * Statutory figures per financial year, keyed '2026-27'. + * + * Nothing is compiled in. The values live in m_tds_fy_config, are edited on + * Organization › TDS Configuration, and are loaded into this registry by + * `TdsConfigService` before any TDS screen opens (see `tdsConfigResolver`). A + * year with no entry makes the computation throw rather than guess. * * For a customer who has opted out onto the old regime, `basicExemption` and * `nilTaxCeiling` are lower and vary by age; pass an override through * `TdsContext.config`. - * - * Thresholds and rates did not change at the 2026 changeover — ₹50,000, - * ₹1,00,000 for senior citizens, 10%, and 20% without PAN carried straight - * across. What changed is the declaration and the return it is reported in. */ -export const FY_TAX_CONFIG: Record = { - '2025-26': { - fy: '2025-26', - regime: 'ITA_1961', - interestTdsSection: 'section 194A', - thresholdSection: 'section 194A(3)(i)', - noPanSection: 'section 206AA', - defaultSection: 'section 201(1A)', - declarationForm: '15G', - declarationRule: 'Rule 29C', - declarationSection: 'section 197A', - tdsStatementForm: 'Form 26Q', - tdsStatementRule: 'Rule 31A', - certificateForm: 'Form 16A', - declarationStatementFrequency: 'QUARTERLY', - declarationStatementDueDay: 15, - thresholdGeneral: 50_000, - thresholdSenior: 100_000, - rate: 0.1, - rateNoPan: 0.2, - basicExemption: 400_000, - nilTaxCeiling: 1_200_000, - roundTo: 1, - lateDeductionRate: 0.01, - lateDepositRate: 0.015 - }, - '2026-27': { - fy: '2026-27', - regime: 'ITA_2025', - // Old section 194A was folded into the omnibus TDS section of the 2025 Act. - interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]', - thresholdSection: 'section 393(4) [Table Sl. 7]', - // 206AA and 206CC merged here. The reduced 5% limb applies only to Sl. 8(ii) - // and 8(v), so interest stays at 20% without PAN. - noPanSection: 'section 397(2)', - defaultSection: 'section 398', - declarationForm: '121', - declarationRule: 'Rule 211 of the Income-tax Rules, 2026', - declarationSection: 'section 393(6)', - tdsStatementForm: 'Form 140', - tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026', - certificateForm: 'Form 131', - // The consolidated statement of declarations received moved from a quarterly - // upload to a monthly one, due on the 7th of the following month. - declarationStatementFrequency: 'MONTHLY', - declarationStatementDueDay: 7, - thresholdGeneral: 50_000, - thresholdSenior: 100_000, - rate: 0.1, - rateNoPan: 0.2, - basicExemption: 400_000, - nilTaxCeiling: 1_200_000, - roundTo: 1, - lateDeductionRate: 0.01, - lateDepositRate: 0.015 +export const FY_TAX_CONFIG: Record = {}; + +/** Bumped on every load, so memoised computations can tell one configuration from the next. */ +export let fyTaxConfigVersion = 0; + +/** Replaces the whole registry with what the platform holds. */ +export function setFyTaxConfigs(configs: FyTaxConfig[]): void { + for (const key of Object.keys(FY_TAX_CONFIG)) { + delete FY_TAX_CONFIG[key]; } -}; + for (const cfg of configs || []) { + FY_TAX_CONFIG[normaliseFy(cfg.fy)] = cfg; + } + fyTaxConfigVersion++; +} + +/** The configured years, latest first. */ +export function configuredFys(): string[] { + return Object.keys(FY_TAX_CONFIG).sort().reverse(); +} // ─────────────────────── Unique Identification Number ─────────────────────── diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-config.fixture.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-config.fixture.ts new file mode 100644 index 000000000..fa3282d83 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-config.fixture.ts @@ -0,0 +1,71 @@ +/** + * 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/. + */ + +/** + * Test-only TDS configuration. The application reads these figures from Organization › TDS + * Configuration; the specs install this copy of the seeded values so they run without a backend. + */ + +import { FyTaxConfig, setFyTaxConfigs } from './tds-15gh.compute'; + +export const TDS_CONFIG_FIXTURE: FyTaxConfig[] = [ + { + fy: '2025-26', + regime: 'ITA_1961', + interestTdsSection: 'section 194A', + thresholdSection: 'section 194A(3)(i)', + noPanSection: 'section 206AA', + defaultSection: 'section 201(1A)', + declarationForm: '15G', + declarationRule: 'Rule 29C', + declarationSection: 'section 197A', + tdsStatementForm: 'Form 26Q', + tdsStatementRule: 'Rule 31A', + certificateForm: 'Form 16A', + declarationStatementFrequency: 'QUARTERLY', + declarationStatementDueDay: 15, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + }, + { + fy: '2026-27', + regime: 'ITA_2025', + interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]', + thresholdSection: 'section 393(4) [Table Sl. 7]', + noPanSection: 'section 397(2)', + defaultSection: 'section 398', + declarationForm: '121', + declarationRule: 'Rule 211 of the Income-tax Rules, 2026', + declarationSection: 'section 393(6)', + tdsStatementForm: 'Form 140', + tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026', + certificateForm: 'Form 131', + declarationStatementFrequency: 'MONTHLY', + declarationStatementDueDay: 7, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + } +]; + +export function installTdsConfigFixture(): void { + setFyTaxConfigs(TDS_CONFIG_FIXTURE); +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-config.service.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-config.service.ts new file mode 100644 index 000000000..26771a849 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-config.service.ts @@ -0,0 +1,150 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { ResolveFn } from '@angular/router'; +import { Observable, catchError, map, of, tap } from 'rxjs'; + +import { FyTaxConfig, setFyTaxConfigs } from './tds-15gh.compute'; + +/** One financial year as the platform stores it — percentages as entered, e.g. 10 for 10%. */ +export interface TdsFyConfigRecord { + id?: number; + fy: string; + regime: string; + interestTdsSection: string | null; + thresholdSection: string | null; + noPanSection: string | null; + defaultSection: string | null; + declarationForm: string; + declarationRule: string | null; + declarationSection: string | null; + tdsStatementForm: string | null; + tdsStatementRule: string | null; + certificateForm: string | null; + declarationStatementFrequency: string; + declarationStatementDueDay: number; + thresholdGeneral: number; + thresholdSenior: number; + ratePanPercentage: number; + rateNoPanPercentage: number; + basicExemption: number; + nilTaxCeiling: number; + roundTo: number; + lateDeductionPercentage: number; + lateDepositPercentage: number; + seedDefault?: boolean; + updatedOn?: string | null; +} + +export interface TdsGlRole { + configKey: string; + label: string; + description: string; + glAccountId: number | null; + glCode: string | null; + glAccountName: string | null; +} + +export interface TdsConfigResponse { + years: TdsFyConfigRecord[]; + glRoles: TdsGlRole[]; +} + +/** + * The single source of TDS thresholds, rates and statutory references. + * + * Every TDS screen computes from `FY_TAX_CONFIG`, and this service is the only thing that fills it. + * Saves go through here too, so the registry is refreshed the moment an administrator changes a + * figure and no open screen keeps computing on the old one. + */ +@Injectable({ providedIn: 'root' }) +export class TdsConfigService { + private http = inject(HttpClient); + + private last: TdsConfigResponse | null = null; + + /** The GL roles as last loaded. */ + get glRoles(): TdsGlRole[] { + return this.last?.glRoles ?? []; + } + + /** Reads the configuration and installs it into the engine. */ + load(): Observable { + return this.http.get('/tdsconfig').pipe(tap((res) => this.install(res))); + } + + /** For route resolvers: never blocks navigation, the screen reports a missing year itself. */ + ensureLoaded(): Observable { + return this.load().pipe( + map(() => true), + catchError(() => { + setFyTaxConfigs([]); + return of(false); + }) + ); + } + + template(): Observable { + return this.http.get('/tdsconfig/template'); + } + + saveYear(record: TdsFyConfigRecord): Observable { + return this.http + .put(`/tdsconfig/year/${record.fy}`, record) + .pipe(tap((res) => this.install(res))); + } + + deleteYear(fy: string): Observable { + return this.http.delete(`/tdsconfig/year/${fy}`).pipe(tap((res) => this.install(res))); + } + + saveGl(configKey: string, glAccountId: number | null): Observable { + return this.http + .put(`/tdsconfig/gl/${configKey}`, { glAccountId }) + .pipe(tap((res) => this.install(res))); + } + + private install(res: TdsConfigResponse): void { + this.last = res; + setFyTaxConfigs((res?.years || []).map(toFyTaxConfig)); + } +} + +/** Stored percentages become the fractions the engine multiplies by. */ +export function toFyTaxConfig(r: TdsFyConfigRecord): FyTaxConfig { + return { + fy: r.fy, + regime: r.regime as FyTaxConfig['regime'], + interestTdsSection: r.interestTdsSection ?? '—', + thresholdSection: r.thresholdSection ?? '—', + noPanSection: r.noPanSection ?? '—', + defaultSection: r.defaultSection ?? '—', + declarationForm: r.declarationForm as FyTaxConfig['declarationForm'], + declarationRule: r.declarationRule ?? '—', + declarationSection: r.declarationSection ?? '—', + tdsStatementForm: r.tdsStatementForm ?? '—', + tdsStatementRule: r.tdsStatementRule ?? '—', + certificateForm: r.certificateForm ?? '—', + declarationStatementFrequency: r.declarationStatementFrequency === 'MONTHLY' ? 'MONTHLY' : 'QUARTERLY', + declarationStatementDueDay: Number(r.declarationStatementDueDay), + thresholdGeneral: Number(r.thresholdGeneral), + thresholdSenior: Number(r.thresholdSenior), + rate: Number(r.ratePanPercentage) / 100, + rateNoPan: Number(r.rateNoPanPercentage) / 100, + basicExemption: Number(r.basicExemption), + nilTaxCeiling: Number(r.nilTaxCeiling), + roundTo: Number(r.roundTo) || 1, + lateDeductionRate: Number(r.lateDeductionPercentage) / 100, + lateDepositRate: Number(r.lateDepositPercentage) / 100 + }; +} + +/** Put on every route whose screen computes TDS, so the figures are in place before it builds. */ +export const tdsConfigResolver: ResolveFn = () => inject(TdsConfigService).ensureLoaded(); diff --git a/src/app/letter-mgmt/letter-mgmt-routing.module.ts b/src/app/letter-mgmt/letter-mgmt-routing.module.ts index c0909eb59..c87d6a202 100644 --- a/src/app/letter-mgmt/letter-mgmt-routing.module.ts +++ b/src/app/letter-mgmt/letter-mgmt-routing.module.ts @@ -16,6 +16,7 @@ import { ShareCertificateComponent } from './certificates/share-certificate/shar import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component'; import { Form60Component } from './certificates/form-60/form-60.component'; import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'; +import { tdsConfigResolver } from './certificates/form-15gh/tds-config.service'; import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component'; import { NocCertificateComponent } from './certificates/noc/noc.component'; import { DatabaseExportComponent } from './database-export/database-export.component'; @@ -68,6 +69,7 @@ const routes: Routes = [ { path: 'letter-mgmt/form-15gh', component: Form15ghComponent, + resolve: { tdsConfig: tdsConfigResolver }, data: { title: 'Form 121', breadcrumb: 'Form 121' } }, { diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index 0c35f6ceb..50723ef0e 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -261,6 +261,32 @@ const routes: Routes = [ import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent), data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' } }, + { + // CRR/SLR percentages and the GL accounts RBI Form 1 and Form 9 read their figures from. + path: 'rbi-reserve-config', + loadComponent: () => + import('./rbi-reserve-config/rbi-reserve-config.component').then((m) => m.RbiReserveConfigComponent), + data: { title: 'RBI Reserve Configuration', breadcrumb: 'RBI Reserve Configuration' } + }, + { + // TDS thresholds, rates and statutory references per FY, and the TDS payable GL. + path: 'tds-config', + loadComponent: () => import('./tds-config/tds-config.component').then((m) => m.TdsConfigComponent), + data: { title: 'TDS Configuration', breadcrumb: 'TDS Configuration' } + }, + { + // OSS 1 keeps its own screen: sixteen roles of its own, none shared with CRR or SLR. + path: 'oss1-gl-config', + loadComponent: () => + import('./rbi-reserve-config/oss1-gl-config.component').then((m) => m.Oss1GlConfigComponent), + data: { title: 'OSS 1 GL Configuration', breadcrumb: 'OSS 1 GL Configuration' } + }, + { + path: 'oss2-gl-config', + loadComponent: () => + import('./rbi-reserve-config/oss2-gl-config.component').then((m) => m.Oss2GlConfigComponent), + data: { title: 'OSS 2 GL Configuration', breadcrumb: 'OSS 2 GL Configuration' } + }, { path: 'employees', data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' }, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index 4bf1aad35..6531fe8b2 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -76,6 +76,64 @@
+ +
+ +
+ +
+
+

RBI Reserve Configuration

+

CRR and SLR rates, and the GL accounts behind Form 1 and Form 9

+
+ +
+
+ + +
+ +
+ +
+
+

TDS Configuration

+

TDS thresholds and rates per financial year, and the TDS payable GL

+
+ +
+
+ + +
+ +
+ +
+
+

OSS 1 GL Configuration

+

+ GL accounts behind OSS 1 — Return on Assets and Liabilities, read as closing balances +

+
+ +
+
+ + +
+ +
+ +
+
+

OSS 2 GL Configuration

+

GL accounts behind OSS 2 — Return on Earnings, read as quarterly movement

+
+ +
+
+
diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index f75312831..5090e143b 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -935,4 +935,71 @@ export class OrganizationService { updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable { return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId }); } + + // ── RBI Reserve Configuration (Form 1 CRR / Form 9 SLR) ── + + /** + * @returns {Observable} `{ rates[], glRoles[] }` — the effective-dated CRR/SLR percentages + * and the GL accounts mapped to each reserve role. Roles with nothing mapped are included, so + * the screen can show the gaps rather than hide them. + */ + getRbiReserveConfig(): Observable { + return this.http.get('/rbireserveconfig'); + } + + /** @returns {Observable} Selectable (postable, non-header) GL accounts plus the rate types. */ + getRbiReserveConfigTemplate(): Observable { + return this.http.get('/rbireserveconfig/template'); + } + + /** + * Replaces the entire set of GL accounts mapped to one reserve role. + * + * Each mapping carries the portion of the account's balance the role takes and, on a liability + * head, whether that portion is a demand or a time liability. RBI treats part of a savings balance + * as demand and the rest as time, so the same account is sent twice under one role with different + * classes - which is why this is a list of mappings rather than a list of account ids. + * + * @param {string} configKey A built-in role (CASH_IN_HAND, BALANCE_WITH_OTHER_BANKS, + * DEMAND_TIME_LIABILITIES, SLR_INVESTMENTS) or the key of a head an administrator added. + */ + updateRbiReserveGlRole( + configKey: string, + mappings: { glAccountId: number; weightPercentage: number; liabilityClass: string | null }[] + ): Observable { + return this.http.put(`/rbireserveconfig/gl/${configKey}`, { mappings, locale: 'en' }); + } + + /** + * Adds a head to a return - a reserve, a tax payable, anything the bank's chart carries that the + * built-in roles do not name. `usage` states how the return consumes it: LIABILITY, ASSET_NET, + * MAINTAINED or MEMO. + */ + createRbiReserveRole(role: { label: string; description: string; group: string; usage: string }): Observable { + return this.http.post('/rbireserveconfig/role', { ...role, locale: 'en' }); + } + + /** Removes an administrator-defined head and the accounts pinned to it. */ + deleteRbiReserveRole(roleId: number): Observable { + return this.http.delete(`/rbireserveconfig/role/${roleId}`); + } + + /** + * Sets a CRR or SLR rate with effect from a date. Restating an existing effective date edits that + * row; a new date adds one, so past returns keep the rate that was actually in force. + */ + upsertRbiReserveRate(rateType: string, percentage: number, effectiveFrom: string): Observable { + return this.http.post('/rbireserveconfig/rate', { + rateType, + percentage, + effectiveFrom, + locale: 'en', + dateFormat: 'yyyy-MM-dd' + }); + } + + /** @returns {Observable} */ + deleteRbiReserveRate(rateId: number): Observable { + return this.http.delete(`/rbireserveconfig/rate/${rateId}`); + } } diff --git a/src/app/organization/rbi-reserve-config/oss1-gl-config.component.ts b/src/app/organization/rbi-reserve-config/oss1-gl-config.component.ts new file mode 100644 index 000000000..5767c2047 --- /dev/null +++ b/src/app/organization/rbi-reserve-config/oss1-gl-config.component.ts @@ -0,0 +1,47 @@ +/** + * 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 } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RbiReserveConfigComponent } from './rbi-reserve-config.component'; + +/** + * GL configuration for OSS 1 — Return on Assets and Liabilities. + * + * Its own screen rather than a section of the reserve configuration: OSS 1 has sixteen roles of its + * own and shares none of them with CRR or SLR, so the two are separate decisions made at separate + * times. The mapping mechanics are identical, so only the scope and the wording differ. + * + * There are no rates here. OSS 1 states a position; it is not a percentage charged on a base. + */ +@Component({ + selector: 'mifosx-oss1-gl-config', + templateUrl: './rbi-reserve-config.component.html', + styleUrls: ['./rbi-reserve-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Oss1GlConfigComponent extends RbiReserveConfigComponent { + protected override readonly group = 'OSS1'; + protected override readonly showRates = false; + + override get screenTitle(): string { + return 'OSS 1 GL Configuration'; + } + + override get screenSubtitle(): string { + return ( + 'The GL accounts OSS 1 — Return on Assets and Liabilities reads its figures from. Each head takes the closing ' + + 'balance of its accounts as at the quarter end. A head left unmapped reports nothing rather than a nil figure ' + + 'that would read as a real position of zero.' + ); + } +} diff --git a/src/app/organization/rbi-reserve-config/oss2-gl-config.component.ts b/src/app/organization/rbi-reserve-config/oss2-gl-config.component.ts new file mode 100644 index 000000000..e6df3064c --- /dev/null +++ b/src/app/organization/rbi-reserve-config/oss2-gl-config.component.ts @@ -0,0 +1,46 @@ +/** + * 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 } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { RbiReserveConfigComponent } from './rbi-reserve-config.component'; + +/** + * GL configuration for OSS 2 — Return on Earnings. + * + * Separate from OSS 1 because the two read their accounts differently, not only because they are + * different returns: OSS 1 takes closing balances, OSS 2 takes the movement over the quarter. The + * same account mapped to both would be right on one and wrong on the other, so the screens are kept + * apart rather than sharing a single list of accounts. + */ +@Component({ + selector: 'mifosx-oss2-gl-config', + templateUrl: './rbi-reserve-config.component.html', + styleUrls: ['./rbi-reserve-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Oss2GlConfigComponent extends RbiReserveConfigComponent { + protected override readonly group = 'OSS2'; + protected override readonly showRates = false; + + override get screenTitle(): string { + return 'OSS 2 GL Configuration'; + } + + override get screenSubtitle(): string { + return ( + 'The GL accounts OSS 2 — Return on Earnings reads its figures from. Each item takes the movement over the ' + + 'reporting quarter, not a closing balance: an earnings line reports what was earned or spent in the period, so ' + + 'the same accounts read as balances would carry forward every earlier quarter as well.' + ); + } +} 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 new file mode 100644 index 000000000..0e5f05cb9 --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -0,0 +1,336 @@ + + +
+
+ +

{{ screenTitle }}

+
+

{{ screenSubtitle }}

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { + + @if (showRates) { +
+

Reserve rates

+

+ Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the rate + that was actually in force then. Saving against a date that already has a rate edits it; a new date adds one. +

+ +
+ @for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) { +
+ + {{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today + + @if (currentRate(type); as rate) { + {{ rate.percentage }}% + @if (rate.seedDefault) { + + Placeholder — confirm the rate actually in force + + } @else { + since {{ rate.effectiveFrom }} + } + } @else { + Not set + {{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute + } +
+ } +
+ +
+ + Rate + + CRR — Form 1 + SLR — Form 9 + + Savings — demand share + + + + + Percentage (%) + + + + + Effective from + + + + +
+ + + + + + + + + + + + + @for (rate of rates; track rate.id) { + + + + + + + + } + +
RatePercentageEffective fromStatus
{{ rate.rateType }}{{ rate.percentage }}%{{ rate.effectiveFrom }} + @if (isFuture(rate)) { + Future-dated + } @else if (currentRate(rate.rateType)?.id === rate.id) { + In force + } @else { + Superseded + } + @if (rate.seedDefault) { + Placeholder + } + + +
+
+ } + + +
+

GL accounts behind each figure

+

+ Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each + one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of + its children, so mapping both would count those children twice. +

+ + @if (unmappedCount) { +
+ {{ unmappedCount }} of {{ glRoles.length }} roles have no accounts mapped. Returns that depend on them will + report why they cannot compute instead of showing a figure. +
+ } + + + @if (showTabs) { +
+ @for (group of roleGroups; track group.name) { + + } +
+ } + + @for (role of visibleRoles; track role.configKey) { +
+
+
+ {{ roleLabel(role) }} + {{ role.configKey }} + {{ roleUsedBy(role) }} +
+ @if (isUnmapped(role)) { + Not mapped + } @else { + + {{ selection[role.configKey].length }} account{{ selection[role.configKey].length === 1 ? '' : 's' }} + + } +
+ +

{{ role.description }}

+ + + @if (selection[role.configKey]?.length) { + + + + + + @if (role.classified) { + + } + + + + + @for (mapping of selection[role.configKey]; track $index; let i = $index) { + + + + @if (role.classified) { + + } + + + } + +
GL accountPortion (%)Head
+ + + @for (o of availableAccounts(role, i); track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + + + + + + + + + @for (c of liabilityClasses; track c.value) { + {{ c.label }} + } + + + + +
+ } + + + @for (entry of unbalancedAccounts(role); track entry.glAccountId) { +
+ {{ glLabel(entry.glAccountId) }} is mapped at {{ entry.total }}% in total. + @if (entry.total < 100) { + {{ 100 - entry.total }}% of its balance will not appear on the return. + } @else { + It would be counted {{ entry.total }}% — more than the account holds. + } +
+ } + +
+ + + + + @if (!role.builtIn) { + + } +
+
+ } + + +
+ @if (!showAddRole) { + +

+ For anything this bank carries that the heads above do not name — a reserve, a tax payable, bills payable. + The return reads it from the same chart of accounts the balance sheet is built from. +

+ } @else { +
+ + Name + + + + + How the return uses it + + @for (u of roleUsages; track u.value) { + {{ u.label }} + } + + + + + Note (optional) + + + + + +
+ } +
+
+ } +
diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss new file mode 100644 index 000000000..4b597578c --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss @@ -0,0 +1,475 @@ +/** + * 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/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; +$green: #137333; +$amber: #b06000; +$red: #c5221f; + +.rrc-container { + max-width: 1040px; + margin: 0 auto; + padding: 20px 16px; +} + +.rrc-header { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 6px; + + &__title { + font-size: 18px; + font-weight: 600; + color: $gray-900; + margin: 0; + } +} + +.rrc-sub { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; + max-width: 820px; + line-height: 1.6; +} + +.rrc-back { + border-radius: 8px !important; + font-size: 12px !important; + font-weight: 600 !important; + height: 32px !important; + border-color: $border !important; + color: $gray-500 !important; + + i { + margin-right: 6px; + } + + &:hover { + color: $primary !important; + border-color: $primary !important; + } +} + +.rrc-alert { + padding: 10px 14px; + border-radius: 8px; + font-size: 12px; + margin-bottom: 14px; + + &--error { + background: #fce8e6; + color: $red; + } + + &--success { + background: #e6f4ea; + color: $green; + } + + &--warn { + background: #fef7e0; + color: $amber; + } +} + +.rrc-loading { + padding: 28px; + text-align: center; + color: $gray-500; + font-size: 13px; +} + +.rrc-card { + background: #fff; + border: 1px solid $border; + border-radius: 12px; + padding: 18px; + margin-bottom: 18px; + + &__title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: $gray-900; + } + + &__hint { + margin: 0 0 16px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } +} + +// ── Rates ── + +.rrc-rate-now { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 18px; + + &__item { + flex: 1 1 220px; + background: $gray-50; + border: 1px solid $border; + border-radius: 10px; + padding: 12px 14px; + } + + &__label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + margin-bottom: 4px; + } + + &__value { + font-size: 22px; + font-weight: 700; + color: $primary; + + &--none { + font-size: 15px; + color: $red; + } + } + + &__since { + display: block; + margin-top: 2px; + font-size: 11px; + color: $gray-500; + + &--warn { + color: $amber; + font-weight: 600; + } + } +} + +.rrc-rate-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 18px; + + &__type { + flex: 0 1 190px; + } + + &__pct { + flex: 0 1 150px; + } + + &__date { + flex: 0 1 190px; + } +} + +// ── Tables ── + +.rrc-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + font-weight: 600; + color: $gray-500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 8px 10px; + border-bottom: 1px solid $border; + } + + td { + padding: 9px 10px; + border-bottom: 1px solid $gray-200; + color: $gray-900; + vertical-align: middle; + } + + tr:last-child td { + border-bottom: none; + } +} + +.rrc-num { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.rrc-key { + display: inline-block; + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: $gray-50; + border: 1px solid $border; + border-radius: 5px; + padding: 2px 6px; + color: $gray-500; +} + +.rrc-badge { + display: inline-block; + font-size: 10.5px; + font-weight: 600; + border-radius: 20px; + padding: 3px 10px; + white-space: nowrap; + + &--active, + &--mapped { + background: #e6f4ea; + color: $green; + } + + &--future { + background: #e8f0fe; + color: $primary; + } + + &--past { + background: $gray-50; + color: $gray-500; + } + + &--unmapped { + background: #fce8e6; + color: $red; + } + + // Shipped with the installation, never confirmed by this bank. + &--seed { + background: #fef7e0; + color: $amber; + margin-left: 6px; + } +} + +// ── GL roles ── + +// One tab per return, so a long role list is configured a return at a time. The tabs wrap rather +// than scroll: a return whose tab had scrolled out of sight would look like one that does not exist. +.rrc-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0 18px; + border-bottom: 1px solid $border; + padding-bottom: 12px; +} + +.rrc-tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid $border; + border-radius: 8px; + background: #fff; + font: inherit; + font-size: 13px; + font-weight: 600; + color: $gray-900; + cursor: pointer; + + &:hover { + background: $gray-50; + } + + &--active { + border-color: $primary; + background: rgba(19, 83, 160, 0.06); + color: $primary; + } + + &__badge { + font-size: 11px; + font-weight: 500; + padding: 1px 7px; + border-radius: 10px; + background: rgba(176, 96, 0, 0.12); + color: $amber; + + // A fully mapped return is worth stating as plainly as an incomplete one, so the tab row can + // be read at a glance instead of by elimination. + &--done { + background: rgba(19, 115, 51, 0.12); + color: $green; + } + } +} + +.rrc-role { + border: 1px solid $border; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 12px; + + // An unmapped role stops a return computing, so it reads as a problem, not a blank. + &--unmapped { + border-color: #f5c6c4; + background: #fffafa; + } + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 4px; + } + + &__name { + font-size: 13.5px; + font-weight: 600; + color: $gray-900; + margin-right: 8px; + } + + &__usedby { + margin-left: 8px; + font-size: 11px; + color: $primary; + font-weight: 600; + } + + &__desc { + margin: 0 0 12px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } + + &__controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + &__select { + flex: 1 1 420px; + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + } +} + +.rrc-remove { + min-width: 0 !important; + padding: 0 8px !important; + color: $gray-500 !important; + + &:hover { + color: $red !important; + } +} + +.rrc-chip { + font-size: 11px; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 3px 10px; +} + +@media (max-width: 720px) { + .rrc-rate-form__type, + .rrc-rate-form__pct, + .rrc-rate-form__date, + .rrc-role__select { + flex: 1 1 100%; + } +} + +/* ── mapping rows ── + A mapping is an account, a portion of its balance, and the head that portion falls under, so it + is laid out as a table rather than as chips: the three parts of one row have to read together. */ +.rrc-map { + width: 100%; + border-collapse: collapse; + margin-top: 0.75rem; + + th { + text-align: left; + font-size: 0.72rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.55); + font-weight: 600; + padding: 0 0.5rem 0.25rem 0; + } + + td { + padding: 0.15rem 0.5rem 0.15rem 0; + vertical-align: middle; + } + + /* The portion and head columns hold short values; letting them take an equal share of the row + would squeeze the account name, which is the part that identifies the row. */ + &__narrow { + width: 8.5rem; + } + + &__actions { + width: 3rem; + text-align: right; + } + + &__field { + width: 100%; + } + + /* An incomplete row is marked where it is, rather than only by the disabled Save button, so it is + clear which row is holding the save back. */ + &__row--invalid td { + background: rgba(214, 106, 0, 0.06); + } +} + +.rrc-alert--inline { + margin: 0.5rem 0 0; + font-size: 0.82rem; +} + +/* ── adding a head ── */ +.rrc-add { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px dashed rgba(0, 0, 0, 0.12); + + &__form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + } + + &__desc { + flex: 1 1 16rem; + } +} diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts new file mode 100644 index 000000000..3499691c4 --- /dev/null +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -0,0 +1,485 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OrganizationService } from '../organization.service'; + +/** + * One GL account pinned to a role: which account, how much of its balance the role takes, and - on a + * liability head - whether that portion is a demand or a time liability. + */ +export interface ReserveMapping { + glAccountId: number; + weightPercentage: number; + liabilityClass: string | null; +} + +/** + * Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL + * accounts the returns read their figures from. + * + * Both halves sit on one screen because they are one decision. A percentage applies to a base, and + * an administrator who sets the rate but never maps the accounts produces a return that looks + * configured and reports nothing — so the screen states, per role, whether it is mapped at all. + * + * The accounts cannot be shipped as defaults: this platform seeds two mutually exclusive charts of + * accounts and real tenants match neither exactly, so cash on hand is 100101 on one chart and + * 101001 on another. Picking one here would read as zero on the charts it does not match. + */ +@Component({ + selector: 'mifosx-rbi-reserve-config', + templateUrl: './rbi-reserve-config.component.html', + styleUrls: ['./rbi-reserve-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class RbiReserveConfigComponent implements OnInit { + private organizationService = inject(OrganizationService); + + /** + * Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1 + * and Form 9, 'OSS1', 'OSS2'. Null shows every role. + * + * A screen per return rather than one screen for all of them: the four roles that drive CRR and + * the thirty that do not are separate decisions, made by different people at different times, + * and a single list makes each one harder to find than it is to make. + */ + protected readonly group: string | null = 'RESERVE'; + + /** Whether the CRR/SLR percentages belong on this screen. Only the reserve returns have rates. */ + protected readonly showRates: boolean = true; + + glRoles: any[] = []; + + /** The roles grouped by the return they feed, in backend order. */ + roleGroups: { name: string; label: string; roles: any[] }[] = []; + + /** Which group's roles are on screen. Unused when the screen is scoped to one group. */ + activeGroup = ''; + rates: any[] = []; + glAccountOptions: any[] = []; + + loading = true; + savingKey: string | null = null; + savingRate = false; + errorMsg = ''; + successMsg = ''; + + /** + * Draft of the per-role mapping, keyed by role. Committed on Save. + * + * A mapping is an account plus the portion of it the role takes and, on a liability head, which + * head that portion falls under. It is not a plain list of account ids because one account can + * appear twice under one role: RBI treats part of a savings balance as a demand liability and the + * rest as time, which is two rows against the same account. + */ + selection: Record = {}; + + /** The heads a liability can fall under, and the label each carries on screen. */ + readonly liabilityClasses = [ + { value: 'DEMAND', label: 'Demand' }, + { value: 'TIME', label: 'Time' } + ]; + + /** How a head an administrator adds is consumed by the return. */ + readonly roleUsages = [ + { value: 'LIABILITY', label: 'Liability — adds to the DTL base' }, + { value: 'ASSET_NET', label: 'Asset — netted off the base (NDTL)' }, + { value: 'MAINTAINED', label: 'Counts toward what is maintained' }, + { value: 'MEMO', label: 'Memo — shown but totals nothing' } + ]; + + /** New-head form. */ + newRole: { label: string; description: string; usage: string } = { label: '', description: '', usage: 'LIABILITY' }; + addingRole = false; + showAddRole = false; + + /** New-rate form. Defaults to today so the common case is one edit and Save. */ + newRate: { rateType: string; percentage: number | null; effectiveFrom: string } = { + rateType: 'CRR', + percentage: null, + effectiveFrom: new Date().toISOString().slice(0, 10) + }; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + config: this.organizationService.getRbiReserveConfig().pipe(catchError(() => of(null))), + template: this.organizationService.getRbiReserveConfigTemplate().pipe(catchError(() => of(null))) + }).subscribe(({ config, template }: any) => { + this.loading = false; + if (!config) { + this.errorMsg = 'Could not load RBI reserve configuration.'; + return; + } + this.applyConfig(config); + this.glAccountOptions = template?.glAccountOptions || []; + }); + } + + private applyConfig(config: any): void { + const allRoles = config.glRoles || []; + // Scoped to this screen's return before anything else, so every count, warning and Save on the + // page speaks about the roles the user can actually see. + this.glRoles = this.group ? allRoles.filter((r: any) => r.group === this.group) : allRoles; + this.rates = config.rates || []; + this.selection = {}; + for (const role of this.glRoles) { + // Read back what is stored rather than re-derived: a weight or a class the administrator set + // deliberately would otherwise be replaced by a default on the next save. + this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({ + glAccountId: a.glAccountId, + weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage, + liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null) + })); + } + this.rebuildGroups(); + } + + /** + * The roles split by the return they feed, so one screen can configure four returns without + * presenting thirty-four roles as a single list where the four that drive CRR are lost among + * the thirty that do not. + * + * Built from the group the backend states rather than from a key prefix: a role's grouping is + * part of what the return means, and duplicating that rule here would let the two drift. + */ + private rebuildGroups(): void { + const byGroup = new Map(); + for (const role of this.glRoles) { + const group = role.group || 'OTHER'; + if (!byGroup.has(group)) { + byGroup.set(group, { label: role.groupLabel || group, roles: [] }); + } + byGroup.get(group)!.roles.push(role); + } + this.roleGroups = [...byGroup.entries()].map(([ + name, + entry + ]) => ({ name, label: entry.label, roles: entry.roles })); + + // Keep the open tab if it still exists, so saving a role does not throw the user back to the + // first tab with their place lost. + if (!this.roleGroups.some((g) => g.name === this.activeGroup)) { + this.activeGroup = this.roleGroups[0]?.name ?? ''; + } + } + + /** Tabs only earn their place when the screen shows more than one return's roles. */ + get showTabs(): boolean { + return this.roleGroups.length > 1; + } + + /** Heading and blurb, overridden per return by the screens scoped to one. */ + get screenTitle(): string { + return 'RBI Reserve Configuration'; + } + + get screenSubtitle(): string { + return ( + 'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' + + 'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' + + 'than reporting a nil figure that looks real.' + ); + } + + /** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */ + isUnmapped(role: any): boolean { + return !(this.selection[role.configKey] || []).length; + } + + get unmappedCount(): number { + return this.glRoles.filter((r) => this.isUnmapped(r)).length; + } + + /** Unmapped roles within one group, so each tab carries its own count. */ + unmappedIn(group: { roles: any[] }): number { + return group.roles.filter((r) => this.isUnmapped(r)).length; + } + + showGroup(name: string): void { + this.activeGroup = name; + } + + get visibleRoles(): any[] { + return this.roleGroups.find((g) => g.name === this.activeGroup)?.roles ?? []; + } + + /** True once the draft differs from what is stored, so Save is only offered when useful. */ + isDirty(role: any): boolean { + const key = (m: any) => `${m.glAccountId}/${m.liabilityClass ?? ''}/${+m.weightPercentage}`; + const stored = (role.accounts || []) + .map((a: any) => key({ ...a, weightPercentage: a.weightPercentage ?? 100 })) + .sort(); + const draft = (this.selection[role.configKey] || []).map(key).sort(); + return stored.length !== draft.length || stored.some((k: string, i: number) => k !== draft[i]); + } + + /** Adds an empty row for this role, for the administrator to pick an account into. */ + addMapping(role: any): void { + // Defaults to the whole balance, and to Time on a liability head. Understating demand is the + // conservative default: demand attracts the heavier treatment, so a forgotten class cannot make + // a bank look compliant when it is not. + this.selection[role.configKey] = [ + ...(this.selection[role.configKey] || []), + { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null } + ]; + } + + removeMapping(role: any, index: number): void { + this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index); + } + + /** + * Accounts still selectable in a given row: everything not already used by ANOTHER row under the + * same head. The same account under a different head is left available, because a demand slice and + * a time slice of one savings account are two different figures, not a duplicate. + */ + availableAccounts(role: any, index: number): any[] { + const rows = this.selection[role.configKey] || []; + const taken = new Set( + rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`) + ); + const current = rows[index]; + return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); + } + + /** A row that cannot be saved - no account chosen, or a portion outside 0-100. */ + isRowInvalid(mapping: ReserveMapping): boolean { + const weight = +mapping.weightPercentage; + return !mapping.glAccountId || isNaN(weight) || weight < 0 || weight > 100; + } + + /** Whether any row of this role is incomplete, so Save is withheld rather than failing server-side. */ + hasInvalidRows(role: any): boolean { + return (this.selection[role.configKey] || []).some((m) => this.isRowInvalid(m)); + } + + /** + * The demand and time subtotals this role's rows declare, as a share of the balances behind them. + * + * Shown because a split is easy to get wrong in a way no single row looks wrong in: two rows + * against one account that together take 90% of it silently drop a tenth of the balance out of the + * base, and only the total reveals it. + */ + weightTotalFor(role: any, glAccountId: number): number { + return (this.selection[role.configKey] || []) + .filter((m) => m.glAccountId === glAccountId) + .reduce((sum, m) => sum + (+m.weightPercentage || 0), 0); + } + + /** Accounts split across more than one row whose portions do not add up to the whole balance. */ + unbalancedAccounts(role: any): { glAccountId: number; total: number }[] { + const rows = this.selection[role.configKey] || []; + const ids = [...new Set(rows.map((m) => m.glAccountId).filter((id) => !!id))]; + return ids + .map((id) => ({ glAccountId: id, total: this.weightTotalFor(role, id) })) + .filter((e) => Math.abs(e.total - 100) > 0.0001); + } + + saveRole(role: any): void { + this.savingKey = role.configKey; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .updateRbiReserveGlRole( + role.configKey, + (this.selection[role.configKey] || []).map((m) => ({ + glAccountId: m.glAccountId, + weightPercentage: +m.weightPercentage, + // Sent as null off a liability head, so a class left over from a head that was retyped + // cannot travel with a figure the split does not apply to. + liabilityClass: role.classified ? m.liabilityClass : null + })) + ) + .pipe( + catchError((err: any) => { + this.savingKey = null; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.savingKey = null; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.roleLabel(role)} updated.`; + }); + } + + saveRate(): void { + if (this.newRate.percentage === null || !this.newRate.effectiveFrom) { + this.errorMsg = 'Enter a percentage and an effective date.'; + return; + } + this.savingRate = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .upsertRbiReserveRate(this.newRate.rateType, this.newRate.percentage, this.newRate.effectiveFrom) + .pipe( + catchError((err: any) => { + this.savingRate = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this rate.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.savingRate = false; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.newRate.rateType} rate saved.`; + this.newRate.percentage = null; + }); + } + + deleteRate(rate: any): void { + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .deleteRbiReserveRate(rate.id) + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this rate.'; + return of(null); + }) + ) + .subscribe((res: any) => { + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = 'Rate removed.'; + }); + } + + /** The rate a return filed today would use — the latest one already in effect. */ + currentRate(rateType: string): any | null { + const today = new Date().toISOString().slice(0, 10); + const inForce = this.rates.filter((r) => r.rateType === rateType && r.effectiveFrom <= today); + return inForce.length ? inForce[0] : null; + } + + ratesFor(rateType: string): any[] { + return this.rates.filter((r) => r.rateType === rateType); + } + + /** A future-dated rate is on record but not yet applied — worth saying so explicitly. */ + isFuture(rate: any): boolean { + return rate.effectiveFrom > new Date().toISOString().slice(0, 10); + } + + /** + * A role's display name and how the return uses it, both as the backend states them. + * + * Taken from the response rather than from a table here: a role only exists because code + * computes something from it, and that code is what knows what it means. A copy in the screen + * would leave a newly added role showing as a raw key. + */ + roleLabel(role: any): string { + return role?.label || role?.configKey || ''; + } + + /** Which return each role feeds, so the consequence of leaving one unmapped is visible. */ + roleUsedBy(role: any): string { + return role?.usedBy || ''; + } + + glLabel(accountId: number): string { + const account = this.glAccountOptions.find((a) => a.id === accountId); + return account ? `${account.glCode} — ${account.name}` : `#${accountId}`; + } + + // ── heads an administrator adds ── + + /** + * Adds a head to this return. The bank's own chart decides what these are - a reserve, a tax + * payable, bills payable - so they are rows rather than a fixed list in code. + */ + addRole(): void { + if (!this.newRole.label.trim()) { + this.errorMsg = 'Give the head a name.'; + return; + } + this.addingRole = true; + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .createRbiReserveRole({ + label: this.newRole.label.trim(), + description: this.newRole.description.trim(), + // Added to the return this screen configures, so a head cannot land on a form the + // administrator is not looking at. + group: this.group ?? this.activeGroup, + usage: this.newRole.usage + }) + .pipe( + catchError((err: any) => { + this.addingRole = false; + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not add this head.'; + return of(null); + }) + ) + .subscribe((res: any) => { + this.addingRole = false; + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = `${this.newRole.label.trim()} added. Map its GL accounts below.`; + this.newRole = { label: '', description: '', usage: 'LIABILITY' }; + this.showAddRole = false; + }); + } + + /** + * Removes a head and everything mapped to it. Confirmed first: the figures it contributed leave + * the return with it, so a return filed after this will not agree with one filed before. + */ + deleteRole(role: any): void { + if (!confirm(`Remove "${this.roleLabel(role)}" and its mapped accounts? Returns will stop counting it.`)) { + return; + } + this.errorMsg = ''; + this.successMsg = ''; + this.organizationService + .deleteRbiReserveRole(role.roleId) + .pipe( + catchError((err: any) => { + this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this head.'; + return of(null); + }) + ) + .subscribe((res: any) => { + if (!res) { + return; + } + this.applyConfig(res); + this.successMsg = 'Head removed.'; + }); + } + + /** How a head is consumed, in words, for the screen to state next to it. */ + usageLabel(usage: string): string { + return this.roleUsages.find((u) => u.value === usage)?.label ?? usage; + } +} diff --git a/src/app/organization/tds-config/tds-config.component.html b/src/app/organization/tds-config/tds-config.component.html new file mode 100644 index 000000000..ce237b314 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.html @@ -0,0 +1,216 @@ + + +
+
+ +

TDS Configuration

+
+

+ Thresholds, rates and statutory references for TDS on deposit interest, per financial year, and the GL account + deducted tax is credited to. The TDS reports, the Form 121 register and each customer's Form 121 tab compute from + these figures only — a year not set up here is a year they refuse to compute. +

+ + @if (errorMsg) { +
{{ errorMsg }}
+ } + @if (successMsg) { +
{{ successMsg }}
+ } + + @if (loading) { +
Loading…
+ } @else { + +
+

GL account

+

+ Only postable liability accounts are listed. Nothing is preselected: the right account differs per chart of + accounts. +

+ + @for (role of glRoles; track role.configKey) { +
+
+
+ {{ role.label }} + {{ role.configKey }} +
+ @if (role.glAccountId) { + {{ role.glCode }} · {{ role.glAccountName }} + } @else { + Not mapped + } +
+

{{ role.description }}

+
+ + GL account + + — Not mapped — + @for (o of glAccountOptions; track o.id) { + {{ o.glCode }} · {{ o.name }} + } + + + +
+
+ } +
+ + +
+

Financial years

+

+ One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting + that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date. +

+ +
+ @for (y of years; track y.fy) { + + } + @if (isNew) { + + } @else { + + } +
+ + @if (draft) { + @if (draft.seedDefault && !isNew) { +
+ These figures were carried over from the values previously built into the application. Check them against + the Finance Act for FY {{ draft.fy }} and save to confirm. +
+ } + + @if (isNew) { +
+ + Financial year + + @if (!fyValid) { + Format: 2027-28 + } @else if (fyTaken) { + FY {{ draft.fy }} is already configured + } + +
+ } + +

Thresholds & rates

+
+ @for (f of rateFields; track f.key) { + + {{ f.label }} + + @if (f.hint) { + {{ f.hint }} + } + + } +
+ @if (noPanBelowRate) { +
The rate without PAN cannot be lower than the rate with PAN.
+ } + +

Declaration (Form 121 / 15G / 15H)

+
+ @for (f of declarationFields; track f.key) { + + {{ f.label }} + @if (f.kind === 'select') { + + @for (o of f.options; track o.value) { + {{ o.label }} + } + + } @else { + + } + @if (f.hint) { + {{ f.hint }} + } + + } +
+ +

Statutory references

+

Printed on the reports, certificates and returns for this year.

+
+ @for (f of statuteFields; track f.key) { + + {{ f.label }} + @if (f.kind === 'select') { + + @for (o of f.options; track o.value) { + {{ o.label }} + } + + } @else { + + } + + } +
+ +
+ + @if (isNew) { + + } @else if (years.length > 1) { + + } + @if (draft.updatedOn && !isNew) { + Last saved {{ draft.updatedOn | slice: 0 : 16 }} + } +
+ } @else { +
+ No financial year is configured. Every TDS screen is unable to compute until one is added. +
+ } +
+ } +
diff --git a/src/app/organization/tds-config/tds-config.component.scss b/src/app/organization/tds-config/tds-config.component.scss new file mode 100644 index 000000000..a6c0adc41 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.scss @@ -0,0 +1,34 @@ +/** + * 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/. + */ + +/* Layout on top of the RBI reserve configuration styles this screen shares. */ + +.tdc-section { + margin: 20px 0 10px; + font-size: 14px; + font-weight: 600; +} + +.tdc-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px 16px; + margin-bottom: 8px; +} + +.tdc-gl { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + + &__field { + flex: 1 1 320px; + max-width: 520px; + } +} diff --git a/src/app/organization/tds-config/tds-config.component.ts b/src/app/organization/tds-config/tds-config.component.ts new file mode 100644 index 000000000..91de87e37 --- /dev/null +++ b/src/app/organization/tds-config/tds-config.component.ts @@ -0,0 +1,275 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { catchError, forkJoin, of } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { + TdsConfigResponse, + TdsConfigService, + TdsFyConfigRecord, + TdsGlRole +} from 'app/letter-mgmt/certificates/form-15gh/tds-config.service'; + +/** One editable field of a financial year, as the form lays it out. */ +interface FieldDef { + key: keyof TdsFyConfigRecord; + label: string; + kind: 'amount' | 'percent' | 'int' | 'text' | 'select'; + hint?: string; + options?: { value: string; label: string }[]; +} + +/** + * TDS on deposit interest: the thresholds, rates and statutory references for each financial year, + * and the GL account deducted tax is credited to. + * + * The TDS reports, the Form 121 register and the customer's Form 121 tab all compute from what is + * saved here - nothing about the tax is compiled into the screens. A year missing here is a year + * those screens refuse to compute. + */ +@Component({ + selector: 'mifosx-tds-config', + templateUrl: './tds-config.component.html', + styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class TdsConfigComponent implements OnInit { + private tdsConfig = inject(TdsConfigService); + + loading = true; + saving = false; + savingGl = false; + errorMsg = ''; + successMsg = ''; + + years: TdsFyConfigRecord[] = []; + glRoles: TdsGlRole[] = []; + glAccountOptions: { id: number; name: string; glCode: string }[] = []; + + /** Year on screen, as a working copy - committed on Save. */ + draft: TdsFyConfigRecord | null = null; + /** True while the draft is a year not yet saved. */ + isNew = false; + newFy = ''; + + /** Draft GL selection per role. */ + glSelection: Record = {}; + + readonly rateFields: FieldDef[] = [ + { key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' }, + { key: 'thresholdSenior', label: 'Threshold – senior citizen (₹)', kind: 'amount', hint: '60 at any time in the year' }, + { key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' }, + { key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' }, + { key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' }, + { key: 'lateDepositPercentage', label: 'Late deposit interest (% per month)', kind: 'percent' }, + { key: 'roundTo', label: 'Round TDS to nearest (₹)', kind: 'int', hint: '1 = nearest rupee' } + ]; + + readonly declarationFields: FieldDef[] = [ + { + key: 'declarationForm', + label: 'Declaration form', + kind: 'select', + options: [ + { value: '121', label: 'Form 121' }, + { value: '15G', label: 'Form 15G / 15H' } + ] + }, + { key: 'basicExemption', label: 'Basic exemption limit (₹)', kind: 'amount' }, + { key: 'nilTaxCeiling', label: 'Nil-tax income ceiling (₹)', kind: 'amount' }, + { + key: 'declarationStatementFrequency', + label: 'Declaration statement', + kind: 'select', + options: [ + { value: 'MONTHLY', label: 'Monthly' }, + { value: 'QUARTERLY', label: 'Quarterly' } + ] + }, + { key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' } + ]; + + readonly statuteFields: FieldDef[] = [ + { + key: 'regime', + label: 'Governing Act', + kind: 'select', + options: [ + { value: 'ITA_2025', label: 'Income Tax Act, 2025' }, + { value: 'ITA_1961', label: 'Income-tax Act, 1961' } + ] + }, + { key: 'interestTdsSection', label: 'Section charging TDS on interest', kind: 'text' }, + { key: 'thresholdSection', label: 'Threshold section', kind: 'text' }, + { key: 'noPanSection', label: 'No-PAN higher rate section', kind: 'text' }, + { key: 'defaultSection', label: 'Default / late interest section', kind: 'text' }, + { key: 'declarationSection', label: 'Declaration section', kind: 'text' }, + { key: 'declarationRule', label: 'Declaration rule', kind: 'text' }, + { key: 'tdsStatementForm', label: 'Quarterly TDS return', kind: 'text' }, + { key: 'tdsStatementRule', label: 'TDS return rule', kind: 'text' }, + { key: 'certificateForm', label: 'TDS certificate', kind: 'text' } + ]; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMsg = ''; + forkJoin({ + config: this.tdsConfig.load().pipe(catchError(() => of(null))), + template: this.tdsConfig.template().pipe(catchError(() => of(null))) + }).subscribe(({ config, template }) => { + this.loading = false; + if (!config) { + this.errorMsg = 'Could not load TDS configuration.'; + return; + } + this.glAccountOptions = template?.glAccountOptions || []; + this.apply(config, this.draft?.fy); + }); + } + + private apply(config: TdsConfigResponse, keepFy?: string): void { + this.years = config.years || []; + this.glRoles = config.glRoles || []; + this.glSelection = {}; + for (const role of this.glRoles) { + this.glSelection[role.configKey] = role.glAccountId; + } + const target = this.years.find((y) => y.fy === keepFy) ?? this.years[0] ?? null; + this.select(target); + } + + select(year: TdsFyConfigRecord | null): void { + this.draft = year ? { ...year } : null; + this.isNew = false; + } + + get unmappedGl(): TdsGlRole[] { + return this.glRoles.filter((r) => !r.glAccountId); + } + + /** The year after the latest configured one, e.g. '2027-28' after '2026-27'. */ + private nextFy(): string { + const latest = this.years[0]?.fy; + const start = latest ? Number(latest.slice(0, 4)) + 1 : new Date().getFullYear(); + return `${start}-${String(start + 1).slice(2)}`; + } + + /** + * Starts a new year from the latest one's figures. The law rarely changes all of them at once, so + * copying is the common case; nothing is saved until the administrator reviews and saves it. + */ + startNewYear(): void { + const base = this.years[0]; + this.newFy = this.nextFy(); + this.draft = base + ? { ...base, id: undefined, fy: this.newFy, seedDefault: false, updatedOn: null } + : ({ + fy: this.newFy, + regime: 'ITA_2025', + declarationForm: '121', + declarationStatementFrequency: 'MONTHLY', + roundTo: 1 + } as TdsFyConfigRecord); + this.isNew = true; + this.successMsg = ''; + } + + cancelNew(): void { + this.select(this.years[0] ?? null); + } + + get fyValid(): boolean { + const m = /^(\d{4})-(\d{2})$/.exec(this.draft?.fy ?? ''); + return !!m && (Number(m[1]) + 1) % 100 === Number(m[2]); + } + + get fyTaken(): boolean { + return this.isNew && this.years.some((y) => y.fy === this.draft?.fy); + } + + /** The no-PAN rate is a penalty rate; below the ordinary rate it would reward a missing PAN. */ + get noPanBelowRate(): boolean { + return !!this.draft && Number(this.draft.rateNoPanPercentage) < Number(this.draft.ratePanPercentage); + } + + get canSave(): boolean { + return !!this.draft && this.fyValid && !this.fyTaken && !this.noPanBelowRate && !this.saving; + } + + save(): void { + if (!this.draft || !this.canSave) { + return; + } + this.saving = true; + this.errorMsg = ''; + this.successMsg = ''; + const fy = this.draft.fy; + this.tdsConfig.saveYear(this.draft).subscribe({ + next: (res) => { + this.saving = false; + this.apply(res, fy); + this.successMsg = `FY ${fy} saved. Every TDS screen now computes with these figures.`; + }, + error: (err) => { + this.saving = false; + this.errorMsg = this.messageOf(err, `Could not save FY ${fy}.`); + } + }); + } + + deleteYear(): void { + if (!this.draft || this.isNew) { + return; + } + const fy = this.draft.fy; + if (!confirm(`Remove the TDS configuration for FY ${fy}? TDS screens will stop computing that year.`)) { + return; + } + this.tdsConfig.deleteYear(fy).subscribe({ + next: (res) => { + this.apply(res); + this.successMsg = `FY ${fy} removed.`; + }, + error: (err) => (this.errorMsg = this.messageOf(err, `Could not remove FY ${fy}.`)) + }); + } + + saveGl(role: TdsGlRole): void { + this.savingGl = true; + this.errorMsg = ''; + this.successMsg = ''; + this.tdsConfig.saveGl(role.configKey, this.glSelection[role.configKey] ?? null).subscribe({ + next: (res) => { + this.savingGl = false; + this.apply(res, this.draft?.fy); + this.successMsg = `${role.label} GL saved.`; + }, + error: (err) => { + this.savingGl = false; + this.errorMsg = this.messageOf(err, `Could not save ${role.label} GL.`); + } + }); + } + + isGlDirty(role: TdsGlRole): boolean { + return (this.glSelection[role.configKey] ?? null) !== (role.glAccountId ?? null); + } + + private messageOf(err: any, fallback: string): string { + return err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || fallback; + } +} diff --git a/src/app/products/current-products/create-current-product/create-current-product.component.html b/src/app/products/current-products/create-current-product/create-current-product.component.html index 6662df7d6..a1ca46345 100644 --- a/src/app/products/current-products/create-current-product/create-current-product.component.html +++ b/src/app/products/current-products/create-current-product/create-current-product.component.html @@ -50,6 +50,7 @@
diff --git a/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts index 252629bc5..7f1b8a8e0 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-accounting-step/current-product-accounting-step.component.ts @@ -127,15 +127,11 @@ export class CurrentProductAccountingStepComponent implements OnInit { private patchAccountingValues(mappings: any) { this.CurrentProductAccountingForm.patchValue({ - savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '', savingsControlAccountId: mappings.savingsControlAccount?.id || '', - transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '', - interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '', incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '', incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '', incomeFromInterestId: mappings.incomeFromInterest?.id || '', overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '', - writeOffAccountId: mappings.writeOffAccount?.id || '', feesReceivableAccountId: mappings.feeReceivableAccount?.id || '', penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '', interestPayableAccountId: mappings.interestPayableAccount?.id || '', @@ -267,14 +263,16 @@ export class CurrentProductAccountingStepComponent implements OnInit { }; if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) { - addControl('savingsReferenceAccountId', true); + // A current account pays no interest, has no write-off and does not use a transfers suspense account, so + // Interest On Savings, Write Off and Transfers In Suspense are not mapped. The keys stay savings-named + // because the backend's GL mapping reads them under those names. + // No Current Reference either: a current account's cash side posts to the branch cash GL (Financial + // Activity: Cash at Main Vault / Teller), the same one the cashier screen uses. + removeControl('savingsReferenceAccountId'); addControl('savingsControlAccountId', true); - addControl('transfersInSuspenseAccountId', true); - addControl('interestOnSavingsAccountId', true); addControl('incomeFromFeeAccountId', true); addControl('incomeFromPenaltyAccountId', true); addControl('incomeFromInterestId', true); - addControl('writeOffAccountId', true); addControl('overdraftPortfolioControlId', false); if (this.accounting.isAccrualAccountingRuleId(accountingRule)) { @@ -297,12 +295,9 @@ export class CurrentProductAccountingStepComponent implements OnInit { } else { removeControl('savingsReferenceAccountId'); removeControl('savingsControlAccountId'); - removeControl('transfersInSuspenseAccountId'); - removeControl('interestOnSavingsAccountId'); removeControl('incomeFromFeeAccountId'); removeControl('incomeFromPenaltyAccountId'); removeControl('incomeFromInterestId'); - removeControl('writeOffAccountId'); removeControl('overdraftPortfolioControlId'); removeControl('feesReceivableAccountId'); removeControl('penaltiesReceivableAccountId'); @@ -345,14 +340,6 @@ export class CurrentProductAccountingStepComponent implements OnInit { pillLabel: 'Asset', pillClass: 'green', rows: [ - { - key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', - controlName: 'savingsReferenceAccountId', - options: this.assetAccountOptions, - required: true - }, { key: 'overdraftPortfolioControlId', label: 'Overdraft Portfolio Control', @@ -396,20 +383,12 @@ export class CurrentProductAccountingStepComponent implements OnInit { rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Current Control Account', + inputLabel: 'Current control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true }, - { - key: 'transfersInSuspenseAccountId', - label: 'Transfers In Suspense Account', - inputLabel: 'Transfers in suspense account', - controlName: 'transfersInSuspenseAccountId', - options: this.liabilityAccountOptions, - required: true - }, { key: 'interestPayableAccountId', label: 'Interest Payable Account', @@ -430,32 +409,8 @@ export class CurrentProductAccountingStepComponent implements OnInit { otherMappingsControlName: 'liabilitiesOtherGlMappings', otherAccountOptions: this.liabilityAccountOptions }, - { - key: 'expenses', - title: 'Expenses', - pillLabel: 'Expense', - pillClass: 'amber', - rows: [ - { - key: 'interestOnSavingsAccountId', - label: 'Interest On Savings Account', - inputLabel: 'Interest on savings account', - controlName: 'interestOnSavingsAccountId', - options: this.expenseAccountOptions, - required: true - }, - { - key: 'writeOffAccountId', - label: 'Write Off Account', - inputLabel: 'Write off account', - controlName: 'writeOffAccountId', - options: this.expenseAccountOptions, - required: true - } - ], - otherMappingsControlName: 'expensesOtherGlMappings', - otherAccountOptions: this.expenseAccountOptions - }, + // No Expenses section: a current account pays no interest and has no write-off, so it maps no expense GL. + // The expensesOtherGlMappings control is kept, so an older product's saved expense mappings survive an edit. { key: 'income', title: 'Income', diff --git a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html index c74008227..f01407d85 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-preview-step/current-product-preview-step.component.html @@ -162,29 +162,16 @@ }}
+
+ Days in Year: + {{ CurrentProduct.interestCalculationDaysInYearType }} Days +
+
Auto Interest Post: {{ CurrentProduct.autoPost | yesNo }}
- @if ( - CurrentProduct.frequencyFixDay !== undefined && - CurrentProduct.frequencyFixDay !== null && - CurrentProduct.frequencyFixDay !== '' - ) { -
- Frequency (Fix Day): - {{ CurrentProduct.frequencyFixDay }} Days -
- } - - @if (CurrentProduct.lastInterestDate) { -
- Last Interest Date: - {{ CurrentProduct.lastInterestDate | dateFormat }} -
- } -

{{ 'labels.inputs.Settings' | translate }}

@@ -282,39 +269,58 @@
@if (CurrentProduct.allowOverdraft) { - @if (CurrentProduct.avgBalanceCalcPeriod) { -
- Avg Balance Calculation Period: - {{ CurrentProduct.avgBalanceCalcPeriod }} -
- } +
+ Maximum OD Limit: + ₹ {{ CurrentProduct.overdraftLimit | formatNumber }} +
- @if (CurrentProduct.validForPeriod) { + @if (CurrentProduct.odMinLimit) {
- Valid for Period: - {{ CurrentProduct.validForPeriod }} -
- } - - @if (CurrentProduct.overdraftLimit !== undefined && CurrentProduct.overdraftLimit !== '') { -
- OD Limit %: - {{ CurrentProduct.overdraftLimit }} -
- } - - @if ( - CurrentProduct.nominalAnnualInterestRateOverdraft !== undefined && - CurrentProduct.nominalAnnualInterestRateOverdraft !== '' - ) { -
- Interest on OD %: - {{ CurrentProduct.nominalAnnualInterestRateOverdraft }} + Minimum OD Limit: + ₹ {{ CurrentProduct.odMinLimit | formatNumber }}
}
- OD Account Closed with Zero Balance in Day Closing: + OD Security Type: + {{ CurrentProduct.odSecurityType | titlecase }} +
+ +
+ Sanction Validity: + {{ CurrentProduct.validForPeriod }} +
+ +
+ Excess Drawing Allowed: + {{ CurrentProduct.odExcessAllowed | yesNo }} +
+ + @if (CurrentProduct.odExcessAllowed) { +
+ Maximum Excess: + {{ CurrentProduct.odExcessLimitPercent }} % of limit +
+
+ Penal Charge on Excess: + {{ CurrentProduct.odPenalRate }} % p.a. +
+ } + +
+ OD Interest Rate: + {{ CurrentProduct.nominalAnnualInterestRateOverdraft }} % p.a. +
+ + @if (CurrentProduct.minOverdraftForInterestCalculation) { +
+ Minimum OD for Interest: + ₹ {{ CurrentProduct.minOverdraftForInterestCalculation | formatNumber }} +
+ } + +
+ End OD Once Fully Repaid: {{ CurrentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
} @@ -453,15 +459,6 @@
Assets
-
- Savings reference account - {{ - formatGlAccountById( - CurrentProduct.savingsReferenceAccountId, - CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions - ) - }} -
Overdraft portfolio control {{ @@ -503,7 +500,7 @@
Liabilities
- Savings control account + Current control account {{ formatGlAccountById( CurrentProduct.savingsControlAccountId, @@ -511,15 +508,6 @@ ) }}
-
- Transfers in suspense account - {{ - formatGlAccountById( - CurrentProduct.transfersInSuspenseAccountId, - CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions - ) - }} -
Interest payable account {{ @@ -542,28 +530,6 @@ }
-
-
Expenses
-
- Interest on savings account - {{ - formatGlAccountById( - CurrentProduct.interestOnSavingsAccountId, - CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
-
- Write off account - {{ - formatGlAccountById( - CurrentProduct.writeOffAccountId, - CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions - ) - }} -
-
-
Income
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html index 11467b21d..1bc750acb 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.html @@ -110,72 +110,126 @@ Overdraft (OD) Facility
+ + @if (!CurrentProductSettingsForm.value.allowOverdraft) { +

+ Overdraft is not allowed on this product. Tick "Is Overdraft Allowed" on the Terms tab to set the OD limits. +

+ }
-
- - Is Overdraft Allowed - -
@if (CurrentProductSettingsForm.value.allowOverdraft) {
- OD Account Closed with Zero Balance in Day Closing + End OD Once Fully Repaid (One-time OD only)

- Automatically closes OD accounts when balance reaches zero during day-end closing. + At day-end, withdraws the OD sanction when the debit balance is cleared to zero. Only the OD ends, not + the account. Leave off for a regular OD: a customer who uses it and repays the same day would lose it.

}
- + @if (CurrentProductSettingsForm.value.allowOverdraft) {
-
- Average Balance Calculation in Period - - 6M - 1Y - 3Y - -
- -
- Valid for Period - - 3M - 1Y - 3Y - -
-
- OD Limit % + Maximum OD Limit (₹) + Highest per account + Enter the maximum OD limit - Interest on OD % - + Minimum OD Limit (₹) + + Lowest per account + @if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) { + Cannot exceed the maximum OD limit + } @else { + Cannot be negative + }
+ +
+ OD Security Type + + @for (option of odSecurityTypeOptions; track option.value) { + {{ option.label }} + } + +

{{ selectedSecurityTypeHint }}

+
+ +
+ Sanction Validity + + @for (option of odValidityOptions; track option.value) { + {{ option.label }} + } + +
+ +
+ Excess Drawing (Over Limit) + + Allow withdrawal beyond the sanctioned limit + + @if (!CurrentProductSettingsForm.value.odExcessAllowed) { +

Off: a withdrawal that would cross the limit is refused.

+ } @else { +
+ + Maximum Excess (% of limit) + + Beyond this, refused + Enter above 0% and up to 25% + + + + Penal Charge on Excess (% p.a.) + + Only on the excess amount + Enter above 0% and up to 50% + +
+ @if (excessExample) { +

{{ excessExample }}

+ } + } +
+ +

+ Each account is sanctioned its own limit within this range, valid for the period above. The OD interest + rate is set on the Terms tab. +

}
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts index de2e04d2d..da3435ff5 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts @@ -7,7 +7,7 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, ValidationErrors, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -40,6 +40,21 @@ export class CurrentProductSettingsStepComponent implements OnInit { { id: 'YEARS', value: 'value.years' } ]; + /** How long an OD sanction on this product runs before it must be renewed. */ + odValidityOptions = [ + { value: '3M', label: '3 Months' }, + { value: '6M', label: '6 Months' }, + { value: '1Y', label: '1 Year' }, + { value: '2Y', label: '2 Years' }, + { value: '3Y', label: '3 Years' } + ]; + + odSecurityTypeOptions = [ + { value: 'CLEAN', label: 'Clean', hint: 'No security needed' }, + { value: 'SECURED', label: 'Secured', hint: 'Against FD lien, property, etc.' }, + { value: 'BOTH', label: 'Both', hint: 'Decided at each sanction' } + ]; + constructor() { this.createCurrentProductSettingsForm(); this.setConditionalControls(); @@ -72,14 +87,15 @@ export class CurrentProductSettingsStepComponent implements OnInit { daysToEscheat: isEditing ? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat : DORMANCY_DEFAULTS.daysToEscheat, - // Overdraft fields + // Overdraft scheme (the OD interest rate lives on the Terms tab) allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false, overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '', - nominalAnnualInterestRateOverdraft: isEditing - ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft - : '', - avgBalanceCalcPeriod: isEditing ? this.CurrentProductsTemplate.avgBalanceCalcPeriod || '1Y' : '1Y', - validForPeriod: isEditing ? this.CurrentProductsTemplate.validForPeriod || '1Y' : '1Y', + odMinLimit: isEditing ? this.CurrentProductsTemplate.odMinLimit : '', + odSecurityType: isEditing ? this.CurrentProductsTemplate.odSecurityType || 'CLEAN' : 'CLEAN', + validForPeriod: this.validityOrDefault(isEditing ? this.CurrentProductsTemplate.validForPeriod : null), + odExcessAllowed: isEditing ? !!this.CurrentProductsTemplate.odExcessAllowed : false, + odExcessLimitPercent: isEditing ? this.CurrentProductsTemplate.odExcessLimitPercent : '', + odPenalRate: isEditing ? this.CurrentProductsTemplate.odPenalRate : '', odAccountClosedZeroBalanceInDayClosing: isEditing ? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false) : false @@ -123,23 +139,123 @@ export class CurrentProductSettingsStepComponent implements OnInit { daysToInactive: [DORMANCY_DEFAULTS.daysToInactive], daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy], daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat], - // Overdraft (OD) Facility + // Overdraft (OD) Facility: the scheme an account's OD sanction is held to allowOverdraft: [false], - overdraftLimit: [ - '', - [Validators.min(0)] - ], - nominalAnnualInterestRateOverdraft: [ - '', - [Validators.min(0)] - ], - avgBalanceCalcPeriod: ['1Y'], + overdraftLimit: [''], + odMinLimit: [''], + odSecurityType: ['CLEAN'], validForPeriod: ['1Y'], + // Excess drawing past the sanctioned limit: off means it is refused + odExcessAllowed: [false], + odExcessLimitPercent: [''], + odPenalRate: [''], odAccountClosedZeroBalanceInDayClosing: [false] }); } + get selectedSecurityTypeHint(): string { + const selected = this.CurrentProductSettingsForm.get('odSecurityType')?.value; + return this.odSecurityTypeOptions.find((option) => option.value === selected)?.hint ?? ''; + } + + /** A worked example on the maximum limit, so the percentages read as rupees. */ + get excessExample(): string { + const value = this.CurrentProductSettingsForm.value; + const limit = Number(value.overdraftLimit); + const percent = Number(value.odExcessLimitPercent); + const penal = Number(value.odPenalRate); + if (!limit || !percent) return ''; + const excess = (limit * percent) / 100; + const inr = (n: number) => '₹' + n.toLocaleString('en-IN', { maximumFractionDigits: 2 }); + let text = `On a ${inr(limit)} limit, up to ${inr(excess)} more may be drawn.`; + if (penal) { + text += ` If ${inr(excess)} stays over the limit for a day, the penal charge is ${inr((excess * penal) / 100 / 365)}.`; + } + return text; + } + + private validityOrDefault(value: string | null): string { + return this.odValidityOptions.some((option) => option.value === value) ? value : '1Y'; + } + + /** The minimum sanction may not exceed the maximum. */ + private odMinWithinMax = (control: AbstractControl): ValidationErrors | null => { + const max = Number(this.CurrentProductSettingsForm?.get('overdraftLimit')?.value); + const min = Number(control.value); + if (control.value === '' || control.value === null || !max) return null; + return min > max ? { exceedsMax: true } : null; + }; + + private applyOverdraftValidators(allowed: boolean) { + const max = this.CurrentProductSettingsForm.get('overdraftLimit'); + const min = this.CurrentProductSettingsForm.get('odMinLimit'); + const security = this.CurrentProductSettingsForm.get('odSecurityType'); + const validity = this.CurrentProductSettingsForm.get('validForPeriod'); + if (allowed) { + max.setValidators([ + Validators.required, + Validators.min(1) + ]); + min.setValidators([ + Validators.min(0), + this.odMinWithinMax + ]); + security.setValidators([Validators.required]); + validity.setValidators([Validators.required]); + } else { + [ + max, + min, + security, + validity + ].forEach((control) => control.clearValidators()); + } + [ + max, + min, + security, + validity + ].forEach((control) => control.updateValueAndValidity({ emitEvent: false })); + this.applyExcessValidators(); + } + + /** The excess cap and penal rate are required only while both OD and excess drawing are on. */ + private applyExcessValidators() { + const form = this.CurrentProductSettingsForm; + const required = !!form.get('allowOverdraft').value && !!form.get('odExcessAllowed').value; + const percent = form.get('odExcessLimitPercent'); + const penal = form.get('odPenalRate'); + percent.setValidators( + required + ? [ + Validators.required, + Validators.min(0.01), + Validators.max(25) + ] + : [] + ); + penal.setValidators( + required + ? [ + Validators.required, + Validators.min(0.01), + Validators.max(50) + ] + : [] + ); + percent.updateValueAndValidity({ emitEvent: false }); + penal.updateValueAndValidity({ emitEvent: false }); + } + setConditionalControls() { + this.CurrentProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowed: boolean) => + this.applyOverdraftValidators(!!allowed) + ); + this.CurrentProductSettingsForm.get('odExcessAllowed').valueChanges.subscribe(() => this.applyExcessValidators()); + this.CurrentProductSettingsForm.get('overdraftLimit').valueChanges.subscribe(() => + this.CurrentProductSettingsForm.get('odMinLimit').updateValueAndValidity({ emitEvent: false }) + ); + this.CurrentProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => { const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency'); const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType'); @@ -168,10 +284,15 @@ export class CurrentProductSettingsStepComponent implements OnInit { if (!formValue.allowOverdraft) { delete formValue.overdraftLimit; - delete formValue.nominalAnnualInterestRateOverdraft; - delete formValue.avgBalanceCalcPeriod; + delete formValue.odMinLimit; + delete formValue.odSecurityType; delete formValue.validForPeriod; delete formValue.odAccountClosedZeroBalanceInDayClosing; + delete formValue.odExcessAllowed; + } + if (!formValue.allowOverdraft || !formValue.odExcessAllowed) { + delete formValue.odExcessLimitPercent; + delete formValue.odPenalRate; } return formValue; diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html index f2350d95a..910e31c4a 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html @@ -13,20 +13,76 @@ Interest On OD
- - Interest Posting Frequency * - - @for (item of interestPostingPeriodTypeData; track item) { - - {{ item.value | translateKey: 'catalogs' }} - - } - - - Interest Posting Frequency {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - - + @if (allowOverdraftControl) { +
+ + Is Overdraft Allowed + +
+ } + @if (!isOverdraftAllowed) { +
+ ℹ️ + Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab. +
+ } + + - - - Frequency (Fix Day) - - Days - - - - Last Interest Date - - - -
-
- ⚠️ - Daily product-based interest accrual enabled — interest calculated EOD on daily closing balance. -
+ @if (isOverdraftAllowed) { +
+ ℹ️ + + Rule: daily OD interest = end-of-day debit balance × rate ÷ days in year, applied to the account at the + posting frequency. + +
+ }
diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts index aaab2e0aa..4decaae32 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts @@ -7,12 +7,21 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormControl, UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { Dates } from 'app/core/utils/dates'; -import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +/** Posting-period ids an OD's interest may be applied on: RBI requires monthly for advances; quarterly is the outer limit. */ +const OD_POSTING_PERIOD_IDS = [ + 4, + 5 +]; + +/** + * Interest on OD: the rate and rules an overdraft on this product is charged by. Interest is worked out daily on the + * end-of-day debit balance and applied to the account at the posting frequency. The fields apply only when the + * Settings tab allows an overdraft, so allowOverdraft (that tab's control) switches their validators on and off. + */ @Component({ selector: 'mifosx-current-product-terms-step', templateUrl: './current-product-terms-step.component.html', @@ -24,14 +33,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; }) export class CurrentProductTermsStepComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); - private dateUtils = inject(Dates); - private settingsService = inject(SettingsService); @Input() CurrentProductsTemplate: any; + /** The Settings tab's allowOverdraft control. */ + @Input() allowOverdraft: AbstractControl; CurrentProductTermsForm: UntypedFormGroup; interestPostingPeriodTypeData: any[] = []; + daysInYearData: Array<{ id: number; label: string }> = [ + { id: 365, label: '365 Days (Actual)' }, + { id: 360, label: '360 Days' } + ]; yesNoOptions: Array<{ value: boolean; label: string }> = [ { value: true, label: 'Yes' }, { value: false, label: 'No' } @@ -41,29 +54,59 @@ export class CurrentProductTermsStepComponent implements OnInit { this.createCurrentProductTermsForm(); } + get isOverdraftAllowed(): boolean { + return !!this.allowOverdraft?.value; + } + + /** + * The Settings tab's own control, so the box can be ticked here without going to that tab first; the two + * checkboxes are one control and always agree. + */ + get allowOverdraftControl(): UntypedFormControl | null { + return this.allowOverdraft instanceof UntypedFormControl ? this.allowOverdraft : null; + } + ngOnInit() { - this.interestPostingPeriodTypeData = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || []; + const postingOptions: any[] = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || []; + const odPostingOptions = postingOptions.filter((option: any) => OD_POSTING_PERIOD_IDS.includes(option.id)); + this.interestPostingPeriodTypeData = odPostingOptions.length ? odPostingOptions : postingOptions; this.setYesNoOptions(this.CurrentProductsTemplate?.yesNoOptions); const isEditing = !!this.CurrentProductsTemplate.id; + const defaultPosting = + this.interestPostingPeriodTypeData.find((option: any) => option.id === 4)?.id ?? + this.interestPostingPeriodTypeData[0]?.id ?? + ''; + const storedPosting = this.CurrentProductsTemplate.interestPostingPeriodType?.id; + const storedDaysInYear = this.CurrentProductsTemplate.interestCalculationDaysInYearType?.id; + const storedPostingOffered = this.interestPostingPeriodTypeData.some((option: any) => option.id === storedPosting); this.CurrentProductTermsForm.patchValue({ - interestPostingPeriodType: this.CurrentProductsTemplate.interestPostingPeriodType?.id || '', - autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true, - frequencyFixDay: isEditing ? (this.CurrentProductsTemplate.frequencyFixDay ?? 30) : 30, - lastInterestDate: this.parseDate(this.CurrentProductsTemplate.lastInterestDate) + interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting, + interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365, + nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '', + minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '', + autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true }); + + // The Settings tab patches allowOverdraft in its own ngOnInit, after this one, so follow its changes as well as + // its current value. + this.applyOverdraftValidators(this.isOverdraftAllowed); + this.allowOverdraft?.valueChanges.subscribe((allowed: boolean) => this.applyOverdraftValidators(!!allowed)); } - private parseDate(dateValue: any): Date | null { - if (!dateValue) return null; - if (Array.isArray(dateValue)) { - return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); - } - if (dateValue instanceof Date) { - return dateValue; - } - return new Date(dateValue); + private applyOverdraftValidators(allowed: boolean) { + const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft'); + rate.setValidators( + allowed + ? [ + Validators.required, + Validators.min(0.01), + Validators.max(50) + ] + : [Validators.min(0)] + ); + rate.updateValueAndValidity({ emitEvent: false }); } private setYesNoOptions(options: any[]) { @@ -90,23 +133,25 @@ export class CurrentProductTermsStepComponent implements OnInit { '', Validators.required ], - autoPost: [true], - frequencyFixDay: [ - 30, + interestCalculationDaysInYearType: [365], + nominalAnnualInterestRateOverdraft: [ + '', [Validators.min(0)] ], - lastInterestDate: [''] + minOverdraftForInterestCalculation: [ + '', + [Validators.min(0)] + ], + autoPost: [true] }); } get CurrentProductTerms() { const formValue = { ...this.CurrentProductTermsForm.value }; - const dateFormat = this.settingsService.dateFormat; - - if (formValue.lastInterestDate instanceof Date) { - formValue.lastInterestDate = this.dateUtils.formatDate(formValue.lastInterestDate, dateFormat) || ''; + if (!this.isOverdraftAllowed) { + delete formValue.nominalAnnualInterestRateOverdraft; + delete formValue.minOverdraftForInterestCalculation; } - - return { ...formValue }; + return formValue; } } diff --git a/src/app/products/current-products/edit-current-product/edit-current-product.component.html b/src/app/products/current-products/edit-current-product/edit-current-product.component.html index 699f0bfce..8cd4d990e 100644 --- a/src/app/products/current-products/edit-current-product/edit-current-product.component.html +++ b/src/app/products/current-products/edit-current-product/edit-current-product.component.html @@ -50,6 +50,7 @@
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html index c2904dd05..f68cdbf07 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.html @@ -257,16 +257,10 @@
} - @if (currentProduct.frequencyFixDay) { + @if (currentProduct.interestCalculationDaysInYearType?.id) {
- Frequency Fix Day - {{ currentProduct.frequencyFixDay }} -
- } - @if (currentProduct.lastInterestDate) { -
- Last Interest Date - {{ currentProduct.lastInterestDate | dateFormat }} + Days in Year + {{ currentProduct.interestCalculationDaysInYearType.id }} Days
}
@@ -396,37 +390,57 @@ {{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }} - {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }} + ₹ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }} } @if (currentProduct.nominalAnnualInterestRateOverdraft) {
{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }} - {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % + {{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.
} @if (currentProduct.overdraftLimit) {
{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }} - {{ currentProduct.overdraftLimit | formatNumber }} + ₹ {{ currentProduct.overdraftLimit | formatNumber }}
} @if (currentProduct.allowOverdraft) { - @if (currentProduct.avgBalanceCalcPeriod) { + @if (currentProduct.odMinLimit) {
- Average Balance Calculation Period - {{ currentProduct.avgBalanceCalcPeriod }} + Minimum OD Limit + ₹ {{ currentProduct.odMinLimit | formatNumber }} +
+ } + @if (currentProduct.odSecurityType) { +
+ OD Security Type + {{ currentProduct.odSecurityType | titlecase }}
} @if (currentProduct.validForPeriod) {
- Valid for Period + Sanction Validity {{ currentProduct.validForPeriod }}
} +
+ Excess Drawing Allowed + {{ currentProduct.odExcessAllowed | yesNo }} +
+ @if (currentProduct.odExcessAllowed) { +
+ Maximum Excess + {{ currentProduct.odExcessLimitPercent | formatNumber }} % of limit +
+
+ Penal Charge on Excess + {{ currentProduct.odPenalRate | formatNumber }} % p.a. +
+ } @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
- OD Account Closed with Zero Balance in Day Closing + End OD Once Fully Repaid {{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}
} @@ -588,10 +602,34 @@

{{ 'labels.heading.Accounting' | translate }}

- + +
+
+ Type + {{ currentProduct.accountingRule?.value || 'Cash' }} +
+
+ Cash GL (deposit / withdrawal) + Branch cash GL — Financial Activity: Cash at Main Vault / Teller +
+ @for (gl of currentAccountingRows; track gl.label) { +
+ {{ gl.label }} + + @if (gl.account) { + ({{ gl.account.glCode }}) {{ gl.account.name }} + } @else { + — + } + +
+ } +
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts index 40c634e85..3b9f19df2 100644 --- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts +++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts @@ -22,7 +22,6 @@ import { MatRowDef, MatRow } from '@angular/material/table'; -import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { YesnoPipe } from '../../../../pipes/yesno.pipe'; @@ -47,7 +46,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatHeaderRow, MatRowDef, MatRow, - ViewSavingsAccountingDetailsComponent, DateFormatPipe, FormatNumberPipe, YesnoPipe @@ -102,4 +100,20 @@ export class CurrentProductGeneralTabComponent { isCashOrAccrualAccounting(): boolean { return this.accounting.isCashOrAccrualAccounting(this.currentProduct.accountingRule); } + + /** The GLs a current product maps, by the names the Accounting tab gives them. */ + get currentAccountingRows(): Array<{ label: string; account: any }> { + const m = this.currentProduct?.accountingMappings || {}; + const rows = [ + { label: 'Current Control (Liability)', account: m.savingsControlAccount }, + { label: 'Overdraft Portfolio Control (Asset)', account: m.overdraftPortfolioControl }, + { label: 'Income From Fees', account: m.incomeFromFeeAccount }, + { label: 'Income From Penalties', account: m.incomeFromPenaltyAccount }, + { label: 'Income From Interest', account: m.incomeFromInterest } + ]; + if (this.currentProduct?.isDormancyTrackingActive) { + rows.push({ label: 'Escheat Liability', account: m.escheatLiabilityAccount }); + } + return rows; + } } diff --git a/src/app/rbi-circulars/oss-live/oss-return.base.ts b/src/app/rbi-circulars/oss-live/oss-return.base.ts new file mode 100644 index 000000000..3ed065606 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss-return.base.ts @@ -0,0 +1,426 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { inject } from '@angular/core'; +import { Observable, catchError, of } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { RbiCircularsService } from '../rbi-circulars.service'; +import { + addBanner, + boxAllCells, + downloadWorkbook, + newWorkbook, + solidFill, + XL +} from '../oss-workbook'; + +/** One GL account behind a return line. */ +export interface OssAccount { + glAccountId: number; + glCode: string; + name: string; + /** Null when the account is pinned but not countable — see `excluded`. */ + balance: number | null; + /** Set when the account is still mapped but left out: disabled, or a header. */ + excluded?: 'disabled' | 'header'; +} + +/** One line of a return, as the backend computes it. */ +export interface OssLine { + label: string; + /** Null on derived rows and headcounts — they are not mapped to accounts. */ + roleKey: string | null; + configured: boolean; + /** Null when the role has no accounts mapped. Never 0 in that case. */ + amount: number | null; + accounts: OssAccount[]; + /** Derived row, summed from the roles it names. */ + subtotal?: boolean; + /** Headcount rather than a rupee amount. */ + count?: boolean; + /** Roles a subtotal is summed from. */ + components?: string[]; +} + +/** Which sheet of the return is on screen. */ +export type OssTab = 'general' | 'figures' | 'signatory'; + +/** A `label -> value` row on the General Information sheet. */ +export interface OssGeneralInfoRow { + label: string; + value: string; + /** False when the particular is not maintained in the institution profile. */ + configured: boolean; +} + +export interface OssSignatory { + heading: string; + name: string; + designation: string; + email: string; + telephoneOffice: string; + place: string; + date: string; +} + +/** The whole return, as the backend computes it. */ +export interface OssReturn { + returnCode: string; + returnName: string; + reportingFrequency: string; + generalInfo: OssGeneralInfoRow[]; + quarter: { label: string; startDate: string; endDate: string }; + officeId: number | null; + officeName: string; + signatories: OssSignatory[]; + basis: { asOnDate?: string; fromDate?: string; toDate?: string; note: string }; + computable: boolean; + unmetRequirements: string[]; + // OSS 1 only. + liabilities?: OssLine[]; + assets?: OssLine[]; + totalLiabilities?: number; + totalAssets?: number; + balanced?: boolean; + imbalance?: number; + // OSS 2 only. + earnings?: OssLine[]; + expenses?: OssLine[]; + totalIncome?: number; + totalExpenditure?: number; + operatingProfit?: number; +} + +/** + * Shared behaviour for the live OSS returns — OSS 1 (RAL) and OSS 2 (RoE). + * + * Nothing is computed in the browser. Every figure, and the bank's own particulars, come from one + * endpoint that reads them from the ledger and the institution profile; re-deriving anything here + * would produce a second set of numbers that could disagree with the filed return. + */ +export abstract class OssReturnBase { + protected rbiService = inject(RbiCircularsService); + + /** Which return to fetch. */ + protected abstract fetch(quarterEndDate: string): Observable; + + /** Filename stem for the Excel export. */ + protected abstract readonly exportStem: string; + + /** Where this return's own GL roles are configured. */ + abstract readonly configRoute: string; + abstract readonly configLinkLabel: string; + + /** 'OSS 1' / 'OSS 2', for the heading. */ + abstract readonly returnShortName: string; + + /** + * The one fact a filer needs before reading any figure — whether the return can be filed as it + * stands. Rendered as HTML so a figure inside it can be emphasised. + */ + abstract get headline(): string; + + /** Whether the headline states a problem, which decides how the banner reads. */ + abstract get headlineIsBad(): boolean; + + /** Sheets of the return, in the order RBI prints them. */ + readonly tabs: { id: OssTab; label: string; icon: string }[] = [ + { id: 'general', label: 'General Information', icon: 'info-circle' }, + { id: 'figures', label: 'Figures', icon: 'university' }, + { id: 'signatory', label: 'Signatory', icon: 'check-circle' } + ]; + + activeTab: OssTab = 'general'; + + showTab(tab: OssTab): void { + this.activeTab = tab; + } + + /** Bank particulars for the header, read back from the General Information sheet. */ + private info(label: string): string { + return this.data?.generalInfo.find((g) => g.label === label)?.value ?? ''; + } + + get bankName(): string { + return this.info('Bank Name'); + } + + get bankCode(): string { + return this.info('Bank Code'); + } + + /** The signatory block as caption/value pairs, in the order every OSS return prints them. */ + signatoryFields(signatory: OssSignatory): { caption: string; value: string }[] { + return [ + { caption: 'Name', value: signatory.name }, + { caption: 'Designation', value: signatory.designation }, + { caption: 'e-mail ID', value: signatory.email }, + { caption: 'Tele. No. (O)', value: signatory.telephoneOffice }, + { caption: 'Place', value: signatory.place }, + { caption: 'Date', value: signatory.date } + ]; + } + + data: OssReturn | null = null; + loading = true; + errorMsg = ''; + generating = false; + + /** Any date in the quarter being reported; the backend resolves it to that quarter. */ + quarterDate = new Date().toISOString().slice(0, 10); + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.fetch(this.quarterDate) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.'; + return of(null); + }) + ) + .subscribe((res: OssReturn | null) => { + this.loading = false; + this.data = res; + }); + } + + onDateChange(): void { + this.load(); + } + + /** Jump to the quarter containing today. */ + resetToToday(): void { + this.quarterDate = new Date().toISOString().slice(0, 10); + this.load(); + } + + // ── presentation helpers ── + + amount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + count(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN'); + } + + protected toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } + + // ── Excel export ── + + /** + * Exports the return as it stands: the same figures the screen shows, the basis, and the + * account-level breakdown, so a reviewer can trace any total back to the accounts behind it. + */ + async exportWorkbook(): Promise { + if (!this.data) { + return; + } + this.generating = true; + try { + const wb = newWorkbook(); + this.buildGeneralInfoSheet(wb); + this.buildFiguresSheet(wb); + this.buildSignatorySheet(wb); + + await downloadWorkbook(wb, `${this.exportStem}_${this.data.quarter.endDate}.xlsx`); + this.toast('Workbook downloaded.'); + } finally { + this.generating = false; + } + } + + private buildGeneralInfoSheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('General Information'); + sheet.columns = [ + { width: 34 }, + { width: 52 }]; + addBanner(sheet, 'General Information', 'B'); + + const start = sheet.rowCount + 1; + for (const row of this.data!.generalInfo) { + const r = sheet.addRow([ + row.label, + row.value + ]); + r.getCell(1).font = { bold: true }; + r.getCell(1).fill = solidFill(XL.keyCell); + // A particular that is not maintained is flagged rather than left looking filled in. + if (!row.configured) { + r.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } }; + } + } + const scope = sheet.addRow([ + 'Office scope', + this.data!.officeName + ]); + scope.getCell(1).font = { bold: true }; + scope.getCell(1).fill = solidFill(XL.keyCell); + boxAllCells(sheet, start, sheet.rowCount, 2); + } + + /** Each section of the return, with its account-level breakdown under every mapped line. */ + protected abstract sections(): { title: string; unit: string; lines: OssLine[] }[]; + + /** Derived totals printed under the figures, in the order the return states them. */ + protected abstract totals(): { label: string; value: number | null | undefined }[]; + + private buildFiguresSheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Figures'); + sheet.columns = [ + { width: 66 }, + { width: 22 }]; + addBanner(sheet, `${this.data!.returnName} — ${this.data!.quarter.label}`, 'B'); + + const noteRow = sheet.addRow([this.data!.basis.note]); + sheet.mergeCells(noteRow.number, 1, noteRow.number, 2); + noteRow.alignment = { wrapText: true }; + noteRow.font = { size: 9, italic: true }; + sheet.addRow([]); + + for (const section of this.sections()) { + const start = sheet.rowCount + 1; + const head = sheet.addRow([ + section.title, + section.unit + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + for (const line of section.lines) { + const row = sheet.addRow([ + line.label, + line.configured ? line.amount : 'Not configured' + ]); + row.getCell(1).alignment = { wrapText: true }; + row.getCell(2).numFmt = line.count ? '#,##0' : '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + if (line.subtotal) { + row.font = { bold: true }; + row.getCell(2).fill = solidFill(XL.derivedBlue); + } + + // An excluded account is named with its reason rather than dropped: the workbook is what + // gets filed, so it must not read as complete when the figure is short by it. + for (const account of line.accounts) { + const sub = sheet.addRow([ + ` ${account.glCode} — ${account.name}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`, + account.excluded ? 'Excluded' : account.balance + ]); + sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } }; + sub.getCell(2).numFmt = '#,##0.00'; + sub.getCell(2).alignment = { horizontal: 'right' }; + } + } + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + } + + const totalsStart = sheet.rowCount + 1; + for (const total of this.totals()) { + const row = sheet.addRow([ + total.label, + total.value ?? null + ]); + row.font = { bold: true }; + row.getCell(2).numFmt = '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + row.getCell(2).fill = solidFill(XL.derivedBlue); + } + boxAllCells(sheet, totalsStart, sheet.rowCount, 2); + + // Caveats travel with the workbook, not only the screen: a return with unmapped roles is + // arithmetically right for what is mapped and still must not be filed as complete. + if (this.data!.unmetRequirements.length) { + sheet.addRow([]); + for (const reason of this.data!.unmetRequirements) { + const row = sheet.addRow([`Check before filing: ${reason}`]); + sheet.mergeCells(row.number, 1, row.number, 2); + row.font = { bold: true, size: 9 }; + row.alignment = { wrapText: true }; + } + } + + sheet.views = [{ state: 'frozen', ySplit: 3 }]; + } + + private buildSignatorySheet(wb: ExcelJS.Workbook): void { + const sheet = wb.addWorksheet('Signatory'); + sheet.columns = [ + { width: 26 }, + { width: 52 }]; + addBanner(sheet, 'Signatory Information', 'B'); + + for (const signatory of this.data!.signatories) { + const start = sheet.rowCount + 1; + const heading = sheet.addRow([signatory.heading]); + sheet.mergeCells(heading.number, 1, heading.number, 2); + heading.font = { bold: true }; + heading.getCell(1).fill = solidFill(XL.headerGrey); + + for (const [ + caption, + value + ] of [ + [ + 'Name', + signatory.name + ], + [ + 'Designation', + signatory.designation + ], + [ + 'e-mail ID', + signatory.email + ], + [ + 'Tele. No. (O)', + signatory.telephoneOffice + ], + [ + 'Place', + signatory.place + ], + [ + 'Date', + signatory.date + ] + ]) { + const row = sheet.addRow([ + caption, + value || 'Not set in Institution Profile' + ]); + row.getCell(1).font = { bold: true }; + row.getCell(1).fill = solidFill(XL.keyCell); + if (!value) { + row.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } }; + } + } + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + } + } +} diff --git a/src/app/rbi-circulars/oss-live/oss-return.component.html b/src/app/rbi-circulars/oss-live/oss-return.component.html new file mode 100644 index 000000000..e087afcb7 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss-return.component.html @@ -0,0 +1,200 @@ + + +
+ @if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
+ + {{ errorMsg }} +
+ } @else if (data) { + +
+
+ +
+

{{ returnShortName }} — {{ data.returnName }}

+
+ {{ bankName }} · Bank Code {{ bankCode }} · Return Code + {{ data.returnCode }} · Quarter ending {{ data.quarter.endDate }} +
+
+
+
+ {{ data.quarter.label }} + + + +
+
+ + +
+ + +
+ + +
+ @for (t of tabs; track t.id) { + + } +
+ + + @if (data.unmetRequirements.length) { +
+ } + + + @if (activeTab === 'general') { +
+
General Information
+
+ + + @for (item of data.generalInfo; track item.label) { + + + + + } + + + + + +
{{ item.label }}{{ item.value }}
Office scope{{ data.officeName }}
+
+

{{ data.basis.note }}

+
+ } + + + @if (activeTab === 'figures') { +
+ @for (section of sections(); track section.title) { +
{{ section.title }}
+
+ + + + + + + + + @for (line of section.lines; track line.label) { + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + } + } + + @for (total of totals(); track total.label) { + + + + + } + +
{{ section.title }}{{ section.unit }}
{{ line.label }} + @if (!line.configured) { + Not configured + } @else if (line.count) { + {{ count(line.amount) }} + } @else { + {{ amount(line.amount) }} + } +
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + {{ amount(account.balance) }}
{{ total.label }}{{ amount(total.value) }}
+
+ } +
+ } + + + @if (activeTab === 'signatory') { +
+
Signatory Information
+ @for (signatory of data.signatories; track signatory.heading) { +
+
{{ signatory.heading }}
+ + + @for (field of signatoryFields(signatory); track field.caption) { + + + + + } + +
{{ field.caption }} + {{ field.value || 'Not set in Institution Profile' }} +
+
+ } +
+ } + } + + @if (toastVisible) { +
{{ toastMsg }}
+ } +
diff --git a/src/app/rbi-circulars/oss-live/oss1-live.component.ts b/src/app/rbi-circulars/oss-live/oss1-live.component.ts new file mode 100644 index 000000000..c7275be99 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss1-live.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, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OssLine, OssReturnBase } from './oss-return.base'; + +/** + * OSS 1 — Return on Assets and Liabilities (RAL), return code R070. + * + * The bank's balance sheet restated on RBI's line structure, every figure read from the ledger as + * at the quarter end. Liabilities must equal assets; where they do not, the return says so rather + * than hiding the difference, because an imbalance is a real condition of the books that the filer + * has to see before filing. + */ +@Component({ + selector: 'mifosx-oss1-live', + templateUrl: './oss-return.component.html', + styleUrls: ['../oss-return.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class Oss1LiveComponent extends OssReturnBase implements OnInit { + protected readonly exportStem = 'RBI_OSS1_RAL'; + readonly configRoute = '/organization/oss1-gl-config'; + readonly configLinkLabel = 'Open OSS 1 GL Configuration'; + readonly returnShortName = 'OSS 1'; + + /** + * Whether the two sides balance — the first thing to check on a balance sheet, and the reason + * the difference is stated rather than absorbed. + */ + get headlineIsBad(): boolean { + return this.data?.balanced === false; + } + + get headline(): string { + if (!this.data) { + return ''; + } + const liabilities = this.amount(this.data.totalLiabilities); + const assets = this.amount(this.data.totalAssets); + if (!this.headlineIsBad) { + return ( + `Total Liabilities ₹${liabilities} · ` + + `Total Assets ₹${assets} · the two sides agree` + ); + } + return ( + `Total Liabilities ₹${liabilities} · ` + + `Total Assets ₹${assets} · ` + + `OUT OF BALANCE by ₹${this.amount(Math.abs(this.data.imbalance ?? 0))} — ` + + 'resolve before filing' + ); + } + + protected fetch(quarterEndDate: string): Observable { + return this.rbiService.getOss1(quarterEndDate); + } + + ngOnInit(): void { + this.load(); + } + + protected sections(): { title: string; unit: string; lines: OssLine[] }[] { + if (!this.data) { + return []; + } + return [ + { title: 'Liabilities', unit: 'Rs in Thousands', lines: this.data.liabilities ?? [] }, + { title: 'Assets', unit: 'Rs in Thousands', lines: this.data.assets ?? [] } + ]; + } + + protected totals(): { label: string; value: number | null | undefined }[] { + if (!this.data) { + return []; + } + const totals: { label: string; value: number | null | undefined }[] = [ + { label: 'Total Liabilities', value: this.data.totalLiabilities }, + { label: 'Total Assets', value: this.data.totalAssets } + ]; + // Stated only when the two sides disagree: printing a nil difference on every balanced return + // would train a reviewer to skip the line that matters when it is not nil. + if (this.data.balanced === false) { + totals.push({ label: 'Difference (Assets − Liabilities)', value: this.data.imbalance }); + } + return totals; + } + + /** True when the two sides of the balance sheet disagree. */ + get isImbalanced(): boolean { + return this.data?.balanced === false; + } +} diff --git a/src/app/rbi-circulars/oss-live/oss2-live.component.ts b/src/app/rbi-circulars/oss-live/oss2-live.component.ts new file mode 100644 index 000000000..7e3ba8f90 --- /dev/null +++ b/src/app/rbi-circulars/oss-live/oss2-live.component.ts @@ -0,0 +1,88 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { OssLine, OssReturnBase } from './oss-return.base'; + +/** + * OSS 2 — Return on Earnings (RoE), return code R071. + * + * The bank's profit and loss account on RBI's line structure. Unlike OSS 1 these lines report the + * movement over the quarter rather than a closing balance: the same accounts read as balances + * would carry forward every earlier quarter's income as well. + */ +@Component({ + selector: 'mifosx-oss2-live', + templateUrl: './oss-return.component.html', + styleUrls: ['../oss-return.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class Oss2LiveComponent extends OssReturnBase implements OnInit { + protected readonly exportStem = 'RBI_OSS2_RoE'; + readonly configRoute = '/organization/oss2-gl-config'; + readonly configLinkLabel = 'Open OSS 2 GL Configuration'; + readonly returnShortName = 'OSS 2'; + + /** A loss is worth flagging; it is a result to explain, not merely a number to read. */ + get headlineIsBad(): boolean { + return (this.data?.operatingProfit ?? 0) < 0; + } + + get headline(): string { + if (!this.data) { + return ''; + } + const result = this.data.operatingProfit ?? 0; + return ( + `Total Income ₹${this.amount(this.data.totalIncome)} · ` + + `Total Expenditure ₹${this.amount(this.data.totalExpenditure)} · ` + + `${result >= 0 ? 'Operating Profit' : 'Operating Loss'} ` + + `₹${this.amount(Math.abs(result))} for the quarter` + ); + } + + protected fetch(quarterEndDate: string): Observable { + return this.rbiService.getOss2(quarterEndDate); + } + + ngOnInit(): void { + this.load(); + } + + protected sections(): { title: string; unit: string; lines: OssLine[] }[] { + if (!this.data) { + return []; + } + return [ + { title: 'Earnings', unit: 'Rs in Thousands', lines: this.data.earnings ?? [] }, + { title: 'Expenditure', unit: 'Rs in Thousands', lines: this.data.expenses ?? [] } + ]; + } + + protected totals(): { label: string; value: number | null | undefined }[] { + if (!this.data) { + return []; + } + return [ + { label: 'Total Income', value: this.data.totalIncome }, + { label: 'Total Expenditure', value: this.data.totalExpenditure }, + { + label: (this.data.operatingProfit ?? 0) >= 0 ? 'Operating Profit' : 'Operating Loss', + value: this.data.operatingProfit + } + ]; + } +} diff --git a/src/app/rbi-circulars/oss-return.scss b/src/app/rbi-circulars/oss-return.scss index ade2b179c..5b4f2989b 100644 --- a/src/app/rbi-circulars/oss-return.scss +++ b/src/app/rbi-circulars/oss-return.scss @@ -567,3 +567,92 @@ td.gl-diff { grid-template-columns: 1fr; } } + +/* ── Live OSS returns (OSS 1, OSS 2) ──────────────────────────────────────── + These read their figures from the ledger rather than from a filed workbook, so they carry two + things a static return does not: a note on which accounts a line came from, and a statement of + what is not mapped. */ + +/* Caveats about the return as a whole: lines with nothing behind them. Sits above the sheets + because a blank line is a fact about the return, not about the sheet it happens to be on. */ +.oss-caveat { + background: var(--o-amber-l); + border: 1px solid #e3d27a; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + color: var(--o-amber-d); + font-size: 12.5px; +} + +.oss-caveat-h { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.oss-caveat-lead { + margin: 6px 0 0; + line-height: 1.6; + opacity: 0.9; +} + +.oss-caveat-list { + margin: 8px 0 8px 18px; + padding: 0; + + li { + margin-bottom: 4px; + } +} + +.oss-caveat-link { + display: inline-block; + margin-top: 4px; + color: var(--o-blue); + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +/* A figure the return could not produce, or an account left out of one. Marked rather than merely + blank, so it reads as deliberately absent instead of as a nil the bank asserted. */ +.oss-unset { + color: var(--o-amber-d); + font-style: italic; + font-weight: 500; +} + +/* The GL accounts behind a line, indented under it. */ +.oss-subline { + td { + font-size: 11.5px; + color: var(--o-g500); + } + + &--excluded td { + color: var(--o-amber-d); + } +} + +.oss-gl-code { + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: var(--o-g50); + border: 1px solid var(--o-g200); + border-radius: 4px; + padding: 1px 5px; + margin-right: 6px; +} + +/* How the figures on a sheet were arrived at — the dates they were read on. */ +.oss-note { + margin: 10px 2px 0; + font-size: 11.5px; + color: var(--o-g500); + line-height: 1.6; +} diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts index 045ce0f06..791827a72 100644 --- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts +++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts @@ -26,16 +26,30 @@ const routes: Routes = [ data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' } }, { + // OSS 1 computed live from the ledger. Lazy — the ExcelJS workbook builder should not weigh + // down the eager bundle. path: 'rbi/oss1', - // Lazy — the ExcelJS workbook builder should not weigh down the eager bundle. - loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent), + loadComponent: () => import('./oss-live/oss1-live.component').then((m) => m.Oss1LiveComponent), data: { title: 'OSS 1 — Return on Assets and Liabilities', breadcrumb: 'OSS 1 (RAL)' } }, { path: 'rbi/oss2', - loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), + loadComponent: () => import('./oss-live/oss2-live.component').then((m) => m.Oss2LiveComponent), data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' } }, + { + // The filed workbooks as they stood before the returns were computed from the ledger. Kept + // reachable so a figure on a live return can be checked against what was actually filed for + // an earlier quarter; they read from static data and do not move. + path: 'rbi/oss1-filed', + loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent), + data: { title: 'OSS 1 — As filed (static)', breadcrumb: 'OSS 1 (as filed)' } + }, + { + path: 'rbi/oss2-filed', + loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent), + data: { title: 'OSS 2 — As filed (static)', breadcrumb: 'OSS 2 (as filed)' } + }, { path: 'rbi/share-capital', // Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not @@ -48,6 +62,18 @@ const routes: Routes = [ path: 'rbi/oss3', loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent), data: { title: 'OSS 3 — Return on Asset Quality', breadcrumb: 'OSS 3 (RAQ-UCB)' } + }, + { + // CRR return, filed twice a month. Lazy like the other returns — it pulls in ExcelJS. + path: 'rbi/form1', + loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent), + data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' } + }, + { + // SLR return, filed at the end of each month. + path: 'rbi/form9', + loadComponent: () => import('./reserve-return/form9-slr.component').then((m) => m.Form9SlrComponent), + data: { title: 'Form 9 — Statutory Liquidity Ratio', breadcrumb: 'Form 9 (SLR)' } } ]) ]; diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 33446834c..6558ba987 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -60,4 +60,75 @@ export class RbiCircularsService { getShareCapitalReturnYears(): Observable { return this.http.get('/shareproducts/capital-return/financial-years'); } + + /** + * Form 1 — CRR, computed live from the ledger for the fortnight the reporting date falls in. + * + * The DTL base and the balances maintained are read as at different dates: the base lags by 45 + * days (snapped back to a reporting Friday), the holdings are as at the reporting date itself. + * The response's `basis` block states both, so the lag is auditable rather than implied. + * + * @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend. + * @param officeId Optional — scopes every figure to one office. + */ + getForm1(reportingDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form1', { params }); + } + + /** + * Form 9 — SLR, computed live on the month-end position. Any date within a month resolves to that + * month's end, since the return reports the position at close. + */ + getForm9(reportingDate?: string, officeId?: number): Observable { + let params = new HttpParams(); + if (reportingDate) { + params = params.set('reportingDate', reportingDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http.get('/rbireservereturn/form9', { params }); + } + + /** + * OSS 1 — Return on Assets and Liabilities, computed live from the ledger. + * + * Every figure is a closing balance as at the quarter end; the bank's own particulars come from + * the institution profile. Any date inside a quarter resolves to that quarter, so a caller never + * has to know which day a quarter ends on. + * + * @param quarterEndDate yyyy-MM-dd — any date in the quarter. Defaults to the business date. + * @param officeId Optional — scopes every figure to one office. + */ + getOss1(quarterEndDate?: string, officeId?: number): Observable { + return this.http.get('/ossreturn/oss1', { params: this.ossParams(quarterEndDate, officeId) }); + } + + /** + * OSS 2 — Return on Earnings, computed live from the ledger. + * + * Earnings lines report the movement over the quarter rather than a closing balance, so the same + * accounts read as balances would carry forward every earlier quarter's income as well. + */ + getOss2(quarterEndDate?: string, officeId?: number): Observable { + return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) }); + } + + private ossParams(quarterEndDate?: string, officeId?: number): HttpParams { + let params = new HttpParams(); + if (quarterEndDate) { + params = params.set('quarterEndDate', quarterEndDate); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return params; + } } diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts new file mode 100644 index 000000000..2db6c5c69 --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts @@ -0,0 +1,41 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ReserveReturnBase } from './reserve-return.base'; + +/** + * RBI Form 1 — Cash Reserve Ratio, filed twice a month. + * + * The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities + * against the cash and bank balances held today. The two sides are deliberately read on different + * dates — see the basis panel on the screen, which states both. + */ +@Component({ + selector: 'mifosx-form1-crr', + templateUrl: './reserve-return.component.html', + styleUrls: ['./reserve-return.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Form1CrrComponent extends ReserveReturnBase implements OnInit { + protected readonly exportStem = 'RBI_Form1_CRR'; + + protected fetch(reportingDate: string): Observable { + return this.rbiService.getForm1(reportingDate); + } + + ngOnInit(): void { + this.load(); + } +} diff --git a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts new file mode 100644 index 000000000..4dee07ffe --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts @@ -0,0 +1,41 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ReserveReturnBase } from './reserve-return.base'; + +/** + * RBI Form 9 — Statutory Liquidity Ratio, filed at the end of the month. + * + * Unlike Form 1 there is no lag: both the liability base and the SLR-eligible investments are read + * as at month end, so the return states the position actually held at close. Any date the user + * picks resolves to that month's end. + */ +@Component({ + selector: 'mifosx-form9-slr', + templateUrl: './reserve-return.component.html', + styleUrls: ['./reserve-return.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule + ] +}) +export class Form9SlrComponent extends ReserveReturnBase implements OnInit { + protected readonly exportStem = 'RBI_Form9_SLR'; + + protected fetch(reportingDate: string): Observable { + return this.rbiService.getForm9(reportingDate); + } + + ngOnInit(): void { + this.load(); + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts new file mode 100644 index 000000000..65642760c --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -0,0 +1,474 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { inject } from '@angular/core'; +import { Observable, catchError, of } from 'rxjs'; +import * as ExcelJS from 'exceljs'; +import { RbiCircularsService } from '../rbi-circulars.service'; +import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook'; + +/** One configured figure on the return, with the accounts it was derived from. */ +export interface ReserveLine { + label: string; + configKey: string; + configured: boolean; + asOnDate: string; + amount: number | null; + /** + * The accounts the figure was derived from. `balance` is null and `excluded` set when an account + * is still pinned to the role but no longer countable — disabled, or turned into a header whose + * balance would double-count its children. + */ + accounts: ReserveLineAccount[]; +} + +/** + * One account behind a line. `accountBalance` is what the ledger holds; `balance` is the portion + * this head takes of it. The two differ whenever `weightPercentage` is under 100 — RBI treats part + * of a savings balance as a demand liability and the rest as time, so the same account can appear + * twice under one head with a different share each time. + */ +export interface ReserveLineAccount { + glAccountId: number; + glCode: string; + name: string; + /** The account's own balance, before the head's share is applied. */ + accountBalance?: number | null; + /** The share of that balance this head takes. 100 means the whole of it. */ + weightPercentage?: number | null; + /** DEMAND or TIME on a liability head; null elsewhere. */ + liabilityClass?: string | null; + balance: number | null; + excluded?: 'disabled' | 'header'; +} + +/** One row of the Memorandum or an Annexure — a stated figure, entering no total. */ +export interface SectionRow { + label: string; + configKey: string; + configured: boolean; + amount: number | null; + accounts: { glAccountId: number; glCode: string; name: string; balance: number }[]; +} + +/** + * Compliance across the whole fortnight. + * + * CRR is not met by the closing day's balance: the full requirement must be held on average over the + * fortnight AND a daily minimum on every single day of it, so a bank that ran empty for thirteen days + * and topped up on the last one fails on the daily test while passing on the closing position. + */ +export interface FortnightMaintenance { + days: { date: string; amount: number }[]; + dayCount: number; + average: number; + lowest: number; + lowestOn: string | null; + /** False while the fortnight is still running, so the average is of the days elapsed. */ + fortnightComplete: boolean; + requiredAverage: number; + dailyMinimumPercent: number; + dailyMinimumAmount: number; + averageMet: boolean; + dailyMinimumMet: boolean; + averageShortfall: number; +} + +/** The whole return, as the backend computes it. */ +export interface ReserveReturn { + formCode: string; + formName: string; + frequency: string; + reportingDate: string; + rateType: string; + ratePercentage: number | null; + /** True while the rate is still the placeholder shipped with the installation. */ + rateIsSeedDefault: boolean; + basis: { + lagDays?: number; + rawLagDate?: string; + /** The nominal reporting Friday, before any shift off a holiday. */ + reportingFriday?: string; + dtlAsOnDate: string; + holdingsAsOnDate: string; + /** True when that Friday was a holiday and the position was taken a working day earlier. */ + shiftedForHoliday?: boolean; + note: string; + }; + lines: ReserveLine[]; + /** Form A's Memorandum and its two Annexures — sections of the return, not lines on it. */ + memorandum?: SectionRow[]; + annexureA?: SectionRow[]; + annexureB?: SectionRow[]; + /** Compliance across the fortnight rather than on the reporting date alone. Form 1 only. */ + maintenance?: FortnightMaintenance; + computable: boolean; + dtlAmount?: number; + requiredAmount?: number; + maintainedAmount?: number; + surplusOrShortfall?: number; + maintainedPercentage?: number | null; + compliant?: boolean; + unmetRequirements: string[]; + // Form 1 only. + fortnight?: number; + fortnightLabel?: string; + fortnightStart?: string; + fortnightEnd?: string; + // Form 9 only. + requestedDate?: string; + monthStart?: string; +} + +/** + * Shared behaviour for the two reserve returns — Form 1 (CRR) and Form 9 (SLR). + * + * Both are the same statement: a percentage of Demand and Time Liabilities must be maintained in a + * defined class of assets, and the return compares what is required against what is held. They + * differ only in which figures make up the maintained side and on which dates each side is read, + * so everything except the fetch and the Excel layout lives here. + * + * Nothing is computed in the browser. The figures come from the ledger through one endpoint that + * already aggregates them; re-deriving totals here would produce a second set of numbers that could + * disagree with the filed return. + */ +export abstract class ReserveReturnBase { + protected rbiService = inject(RbiCircularsService); + + /** Which return to fetch. */ + protected abstract fetch(reportingDate: string): Observable; + + /** Filename stem for the Excel export. */ + protected abstract readonly exportStem: string; + + data: ReserveReturn | null = null; + loading = true; + errorMsg = ''; + generating = false; + + /** The reporting date the user is looking at. Editable, so past periods can be reviewed. */ + reportingDate = new Date().toISOString().slice(0, 10); + + toastMsg = ''; + toastVisible = false; + private toastTimer: any; + + load(): void { + this.loading = true; + this.errorMsg = ''; + this.fetch(this.reportingDate) + .pipe( + catchError((err: any) => { + this.errorMsg = + err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.'; + return of(null); + }) + ) + .subscribe((res: ReserveReturn | null) => { + this.loading = false; + this.data = res; + }); + } + + onDateChange(): void { + this.load(); + } + + /** Jump to the period containing today. */ + resetToToday(): void { + this.reportingDate = new Date().toISOString().slice(0, 10); + this.load(); + } + + // ── presentation helpers ── + + /** + * The titled sections Form A carries alongside the main statement, in the order the format states + * them. Each is skipped by the template when it has no rows, so Form 9 — which has none — is + * unaffected. + */ + get namedSections(): { title: string; hint: string; rows: SectionRow[] }[] { + if (!this.data) { + return []; + } + return [ + { + title: 'Memorandum to Form A', + hint: 'Stated alongside the return. These figures are reported, not added into the CRR requirement.', + rows: this.data.memorandum ?? [] + }, + { + title: 'Annexure A — Foreign currency liabilities and assets', + hint: 'Reported in INR equivalent at the applicable reference rate. Nil where the bank has no foreign-currency business.', + rows: this.data.annexureA ?? [] + }, + { + title: 'Annexure B — Investment details', + hint: 'Investments split between approved and non-approved securities, shares, debentures and bonds.', + rows: this.data.annexureB ?? [] + } + ]; + } + + get isShortfall(): boolean { + return !!this.data?.computable && this.data.compliant === false; + } + + /** Magnitude of the gap, so the sign lives in the label rather than in the number. */ + get shortfallAmount(): number { + return Math.abs(this.data?.surplusOrShortfall ?? 0); + } + + /** + * True when a head takes only part of an account, so the printed figure is not that account's + * balance. Anything at or above 100, or unset, is the whole balance and needs no explanation. + */ + isPartial(account: ReserveLineAccount): boolean { + const weight = account.weightPercentage; + return weight !== null && weight !== undefined && weight < 100 && account.accountBalance != null; + } + + amount(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + percent(value: number | null | undefined): string { + if (value === null || value === undefined) { + return '—'; + } + return `${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}%`; + } + + protected toast(msg: string): void { + this.toastMsg = msg; + this.toastVisible = true; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + } + + // ── Excel export ── + + /** + * Exports the return as filed: the same figures the screen shows, plus the basis dates and the + * account-level breakdown, so a reviewer can trace any total back to the accounts behind it. + */ + async exportWorkbook(): Promise { + if (!this.data) { + return; + } + this.generating = true; + try { + const wb = newWorkbook(); + const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' ')); + sheet.columns = [ + { width: 52 }, + { width: 22 }, + { width: 20 }]; + + addBanner(sheet, `${this.data.formName} — ${this.data.frequency}`, 'C'); + + let start = sheet.rowCount + 1; + this.addKeyValue(sheet, 'Reporting date', this.data.reportingDate); + if (this.data.fortnightLabel) { + this.addKeyValue(sheet, 'Fortnight', `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})`); + } + this.addKeyValue(sheet, 'DTL base as at', this.data.basis.dtlAsOnDate); + this.addKeyValue(sheet, 'Balances maintained as at', this.data.basis.holdingsAsOnDate); + if (this.data.basis.rawLagDate) { + this.addKeyValue(sheet, `Lag (${this.data.basis.lagDays} days) falls on`, this.data.basis.rawLagDate); + } + this.addKeyValue( + sheet, + `${this.data.rateType} rate applied`, + this.data.ratePercentage === null + ? 'Not set' + : `${this.data.ratePercentage}%${this.data.rateIsSeedDefault ? ' (placeholder — not confirmed)' : ''}` + ); + boxAllCells(sheet, start, sheet.rowCount, 2); + sheet.addRow([]); + + // Basis note, spelled out so the export is self-explanatory away from the screen. + const noteRow = sheet.addRow([this.data.basis.note]); + sheet.mergeCells(noteRow.number, 1, noteRow.number, 3); + noteRow.alignment = { wrapText: true }; + noteRow.font = { size: 9, italic: true }; + sheet.addRow([]); + + // Figures. + start = sheet.rowCount + 1; + const head = sheet.addRow([ + 'Particulars', + 'As at', + 'Amount (INR)' + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + for (const line of this.data.lines) { + const row = sheet.addRow([ + line.label, + line.asOnDate, + line.configured ? line.amount : 'Not configured' + ]); + row.getCell(1).alignment = { wrapText: true }; + row.getCell(3).numFmt = '#,##0.00'; + row.getCell(3).alignment = { horizontal: 'right' }; + + // Account-level breakdown, indented under its line. An excluded account is named with its + // reason rather than dropped, so the workbook cannot read as complete when the figure is + // short by it — the file is what gets filed, so the caveat has to travel in it. + for (const account of line.accounts) { + // A partial share is named on the row too: without it the figure reads as the account's + // own balance and cannot be reconciled back to the ledger. + const portion = this.isPartial(account) + ? ` (${account.weightPercentage}% of ${this.amount(account.accountBalance)})` + : ''; + const sub = sheet.addRow([ + ` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`, + account.liabilityClass ?? '', + account.excluded ? 'Excluded' : account.balance + ]); + sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } }; + sub.getCell(3).numFmt = '#,##0.00'; + sub.getCell(3).alignment = { horizontal: 'right' }; + } + } + + if (this.data.computable) { + const required = sheet.addRow([ + `${this.data.rateType} required (${this.data.ratePercentage}% of DTL)`, + '', + this.data.requiredAmount ?? null + ]); + const maintained = sheet.addRow([ + `${this.data.rateType} maintained`, + '', + this.data.maintainedAmount ?? null + ]); + const gap = sheet.addRow([ + this.data.compliant ? 'Surplus' : 'Shortfall', + '', + this.data.surplusOrShortfall ?? null + ]); + for (const row of [ + required, + maintained, + gap + ]) { + row.font = { bold: true }; + row.getCell(3).numFmt = '#,##0.00'; + row.getCell(3).alignment = { horizontal: 'right' }; + row.getCell(3).fill = solidFill(XL.derivedBlue); + } + } + boxAllCells(sheet, start, sheet.rowCount, 3); + + // Fortnight maintenance, which is what CRR compliance actually turns on. A workbook showing + // only the closing position would let a fortnight that breached the daily floor look clean. + if (this.data.maintenance) { + const m = this.data.maintenance; + sheet.addRow([]); + const head = sheet.addRow([ + 'Maintenance over the fortnight', + '', + '' + ]); + head.font = { bold: true }; + head.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + const start = sheet.rowCount + 1; + this.addKeyValue(sheet, 'Average maintained', this.amount(m.average)); + this.addKeyValue(sheet, 'Required average', this.amount(m.requiredAverage)); + this.addKeyValue(sheet, 'Average test', m.averageMet ? 'Met' : 'NOT MET'); + this.addKeyValue(sheet, `Daily floor (${m.dailyMinimumPercent}% of requirement)`, this.amount(m.dailyMinimumAmount)); + this.addKeyValue(sheet, 'Lowest day', `${this.amount(m.lowest)} on ${m.lowestOn}`); + this.addKeyValue(sheet, 'Daily minimum test', m.dailyMinimumMet ? 'Met' : 'NOT MET'); + this.addKeyValue(sheet, 'Days counted', `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}`); + boxAllCells(sheet, start, sheet.rowCount, 2); + + sheet.addRow([]); + const dayHead = sheet.addRow([ + 'Date', + 'Maintained (INR)', + 'Against floor' + ]); + dayHead.font = { bold: true }; + dayHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + const dayStart = sheet.rowCount; + for (const day of m.days) { + const row = sheet.addRow([ + day.date, + day.amount, + day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK' + ]); + row.getCell(2).numFmt = '#,##0.00'; + row.getCell(2).alignment = { horizontal: 'right' }; + } + boxAllCells(sheet, dayStart, sheet.rowCount, 3); + } + + // The Memorandum and Annexures, each under its own heading. + for (const section of this.namedSections) { + if (!section.rows.length) { + continue; + } + sheet.addRow([]); + const title = sheet.addRow([ + section.title, + '', + '' + ]); + title.font = { bold: true }; + title.eachCell((c) => (c.fill = solidFill(XL.headerGrey))); + + const start = sheet.rowCount; + for (const row of section.rows) { + const excelRow = sheet.addRow([ + row.label, + '', + row.configured ? row.amount : 'Not configured' + ]); + excelRow.getCell(1).alignment = { wrapText: true }; + excelRow.getCell(3).numFmt = '#,##0.00'; + excelRow.getCell(3).alignment = { horizontal: 'right' }; + } + boxAllCells(sheet, start, sheet.rowCount, 3); + } + + // Caveats travel with the workbook, not only the screen: a return computed on the placeholder + // rate is arithmetically right and still must not be filed, and the file is what gets sent on. + if (this.data.unmetRequirements.length) { + sheet.addRow([]); + const prefix = this.data.computable ? 'Check before filing' : 'Not computed'; + for (const reason of this.data.unmetRequirements) { + const row = sheet.addRow([`${prefix}: ${reason}`]); + sheet.mergeCells(row.number, 1, row.number, 3); + row.font = { bold: true, size: 9 }; + row.alignment = { wrapText: true }; + } + } + + await downloadWorkbook(wb, `${this.exportStem}_${this.data.reportingDate}.xlsx`); + this.toast('Workbook downloaded.'); + } finally { + this.generating = false; + } + } + + private addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void { + const row = sheet.addRow([ + label, + value + ]); + row.getCell(1).font = { bold: true }; + row.getCell(1).fill = solidFill(XL.keyCell); + } +} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html new file mode 100644 index 000000000..5dcc3af53 --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -0,0 +1,320 @@ + + +
+ @if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
{{ errorMsg }}
+ } @else if (data) { + +
+
+

{{ data.formName }}

+ {{ data.frequency }} + @if (data.fortnightLabel) { + + {{ data.fortnightLabel }} · {{ data.fortnightStart }} to {{ data.fortnightEnd }} + + } @else { + Month ended {{ data.reportingDate }} + } +
+ +
+ + Reporting date + + + + +
+
+ + + @if (data.unmetRequirements.length) { +
+ + {{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }} + +
    + @for (reason of data.unmetRequirements; track reason) { +
  • {{ reason }}
  • + } +
+ + Open RBI Reserve Configuration → + +
+ } + + +
+
+
+ DTL base as at + {{ data.basis.dtlAsOnDate }} +
+
+
+ Balances maintained as at + {{ data.basis.holdingsAsOnDate }} +
+ @if (data.basis.rawLagDate) { +
+ {{ data.basis.lagDays }}-day lag falls on + {{ data.basis.rawLagDate }} +
+ } +
+

{{ data.basis.note }}

+
+ + + @if (data.computable) { +
+ +
+ NDTL base + {{ amount(data.ndtlAmount) }} + + DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at + {{ data.basis.dtlAsOnDate }} + +
+
+ {{ data.rateType }} rate + {{ data.ratePercentage }}% + @if (data.rateIsSeedDefault) { + Placeholder — not confirmed + } @else { + in force on {{ data.reportingDate }} + } +
+
+ Required + {{ amount(data.requiredAmount) }} + {{ data.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(data.maintainedAmount) }} + {{ percent(data.maintainedPercentage) }} of NDTL +
+
+ {{ isShortfall ? 'Shortfall' : 'Surplus' }} + {{ amount(shortfallAmount) }} + {{ isShortfall ? 'Below requirement' : 'Above requirement' }} +
+
+ } + + +
+ + + + + + + + + + + @for (line of data.lines; track $index) { + + + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } + } + + @if (data.computable) { + + + + + + + + + + + + + + + + } + +
ParticularsAs atAmount (INR)
+ {{ line.label }} + @if (!line.configured) { + Not configured + } + {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + + @if (account.excluded) { + excluded — {{ account.excluded }} + } + + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL){{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained{{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }}{{ amount(shortfallAmount) }}
+
+ + + @if (data.maintenance; as m) { +
+

+ Maintenance over the fortnight + @if (!m.fortnightComplete) { + In progress — {{ m.dayCount }} days so far + } +

+

+ CRR is reckoned over the whole fortnight, not on one day: the full requirement must be held + on average, and at least {{ m.dailyMinimumPercent }}% of it on every single day. +

+ +
+
+ Average maintained + {{ amount(m.average) }} + + {{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }} + +
+
+ Lowest day + {{ amount(m.lowest) }} + + on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} + +
+
+ Days counted + {{ m.dayCount }} + {{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }} +
+
+ + + + + + + + + + + @for (day of m.days; track day.date) { + + + + + + } + +
DateMaintained (INR)Against floor
{{ day.date }}{{ amount(day.amount) }} + @if (day.amount < m.dailyMinimumAmount) { + Below floor + } @else { + OK + } +
+
+ } + + + @for (section of namedSections; track section.title) { + @if (section.rows.length) { +
+

{{ section.title }}

+

{{ section.hint }}

+ + + + + + + + + @for (row of section.rows; track row.configKey) { + + + + + } + +
ParticularsAmount (INR)
+ {{ row.label }} + @if (!row.configured) { + Not configured + } + + @if (row.configured) { + {{ amount(row.amount) }} + } @else { + — + } +
+
+ } + } + } + + @if (toastVisible) { +
{{ toastMsg }}
+ } +
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss new file mode 100644 index 000000000..6577a52db --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss @@ -0,0 +1,491 @@ +/** + * 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/. + */ +$primary: #1353a0; +$border: #e2e8f0; +$gray-50: #f8f9fa; +$gray-200: #e8eaed; +$gray-500: #9aa0a6; +$gray-900: #202124; +$green: #137333; +$amber: #b06000; +$red: #c5221f; + +.rr-container { + max-width: 1120px; + margin: 0 auto; + padding: 20px 16px; +} + +.rr-loading { + padding: 36px; + text-align: center; + color: $gray-500; + font-size: 13px; +} + +.rr-alert { + padding: 12px 16px; + border-radius: 8px; + font-size: 12.5px; + margin-bottom: 16px; + line-height: 1.6; + + &--error { + background: #fce8e6; + color: $red; + } + + &--warn { + background: #fef7e0; + color: $amber; + } +} + +.rr-unmet { + margin: 8px 0 8px 18px; + padding: 0; + + li { + margin-bottom: 4px; + } + + // Sets up the list that follows: without it, a blank line reads as an error rather than as a + // head the bank has nothing under. + &__lead { + margin: 6px 0 0; + font-size: 12px; + font-weight: 400; + line-height: 1.6; + opacity: 0.9; + } + + &__link { + display: inline-block; + margin-top: 4px; + font-weight: 600; + color: $primary; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +// ── Header ── + +.rr-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 16px; + + &__title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: $gray-900; + } + + &__freq { + display: inline-block; + margin-top: 4px; + margin-right: 10px; + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: #e8f0fe; + color: $primary; + border-radius: 20px; + padding: 3px 10px; + } + + &__period { + font-size: 12px; + color: $gray-500; + } + + &__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + + i { + margin-right: 6px; + } + } +} + +.rr-date { + width: 180px; +} + +// ── Basis ── + +.rr-basis { + background: $gray-50; + border: 1px solid $border; + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 16px; + + &__title { + margin: 0 0 12px; + font-size: 13px; + font-weight: 700; + color: $gray-900; + } + + &__row { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + } + + // The General Information particulars. A grid rather than a stack: eleven one-line facts read as + // a block to scan, not as a list to work down, and auto-fit keeps them legible at any width. + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px 20px; + } + + &__item { + min-width: 0; + + &--muted { + opacity: 0.65; + } + } + + &__label { + display: block; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + } + + &__value { + font-size: 14px; + font-weight: 600; + color: $gray-900; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + + // A particular nobody has maintained yet, shown as such rather than as a filled-in blank. + &--unset { + font-weight: 500; + font-style: italic; + color: $amber; + } + } + + &__arrow { + color: $gray-500; + font-size: 12px; + } + + &__note { + margin: 10px 0 0; + font-size: 11.5px; + color: $gray-500; + line-height: 1.6; + } +} + +// ── Summary stats ── + +.rr-summary { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} + +.rr-stat { + flex: 1 1 180px; + background: #fff; + border: 1px solid $border; + border-radius: 10px; + padding: 12px 14px; + + &__label { + display: block; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $gray-500; + margin-bottom: 4px; + } + + &__value { + display: block; + font-size: 19px; + font-weight: 700; + color: $gray-900; + font-variant-numeric: tabular-nums; + } + + &__sub { + display: block; + margin-top: 2px; + font-size: 11px; + color: $gray-500; + + &--warn { + color: $amber; + font-weight: 600; + } + } + + // The rate is still the shipped placeholder — the figures below it are only as good as it is. + &--provisional { + border-color: #fde68a; + background: #fffdf5; + } + + // A shortfall is a regulatory breach, so it reads as one rather than as another number. + &--bad { + border-color: #f5c6c4; + background: #fffafa; + + .rr-stat__value { + color: $red; + } + } + + &--good { + border-color: #ceead6; + background: #fafffb; + + .rr-stat__value { + color: $green; + } + } +} + +// ── Table ── + +.rr-card { + background: #fff; + border: 1px solid $border; + border-radius: 12px; + padding: 6px 16px 14px; + overflow-x: auto; +} + +.rr-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + font-weight: 600; + color: $gray-500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 10px 10px; + border-bottom: 1px solid $border; + white-space: nowrap; + } + + td { + padding: 9px 10px; + border-bottom: 1px solid $gray-200; + color: $gray-900; + } + + &__num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } +} + +.rr-line { + &__label { + font-weight: 600; + } + + &__date { + color: $gray-500; + font-size: 11.5px; + white-space: nowrap; + } + + &--unconfigured { + background: #fffafa; + } +} + +.rr-subline { + td { + padding-top: 5px; + padding-bottom: 5px; + font-size: 11.5px; + color: $gray-500; + border-bottom: none; + } + + td:first-child { + padding-left: 26px; + } + + &__code { + font-family: 'Roboto Mono', monospace; + font-size: 10.5px; + background: $gray-50; + border: 1px solid $border; + border-radius: 4px; + padding: 1px 5px; + margin-right: 6px; + } + + // Only part of the account is taken, so the figure alongside is not its balance. + &__portion { + margin-left: 8px; + font-size: 10.5px; + font-weight: 600; + color: $amber; + background: #fef7e0; + border-radius: 20px; + padding: 1px 8px; + white-space: nowrap; + } + + &__class { + margin-left: 6px; + font-size: 10px; + font-weight: 600; + color: $primary; + background: #e8f0fe; + border-radius: 20px; + padding: 1px 8px; + } + + // An account pinned to the role but not in the total. Marked rather than merely blank, so it + // reads as deliberately left out instead of as an account that happens to be nil. + &--excluded td { + color: $amber; + } + + &__excluded { + font-size: 10.5px; + font-style: italic; + margin-left: 8px; + color: $amber; + } +} + +.rr-total { + td { + font-weight: 700; + background: #e8f0fe; + border-bottom: 1px solid #d2e3fc; + } + + &--bad td { + background: #fce8e6; + color: $red; + border-bottom-color: #f5c6c4; + } +} + +.rr-dash { + color: $gray-500; +} + +// ── Sections: Memorandum, Annexures, fortnight maintenance ── + +.rr-card--section { + margin-top: 16px; + padding: 16px; +} + +.rr-section { + &__title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; + color: $gray-900; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + &__hint { + margin: 0 0 14px; + font-size: 12px; + color: $gray-500; + line-height: 1.6; + } +} + +// A day below the daily floor is a breach on its own, even where the average passes. +.rr-day--short td { + background: #fffafa; +} + +.rr-ok { + font-size: 11px; + color: $green; + font-weight: 600; +} + +.rr-badge { + display: inline-block; + margin-left: 8px; + font-size: 10px; + font-weight: 600; + border-radius: 20px; + padding: 2px 8px; + + &--unmapped { + background: #fce8e6; + color: $red; + } + + // The fortnight is still running, so the average is of the days elapsed so far. + &--partial { + background: #fef7e0; + color: $amber; + } +} + +.rr-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: $gray-900; + color: #fff; + font-size: 12.5px; + padding: 10px 18px; + border-radius: 8px; + z-index: 1000; +} + +@media (max-width: 720px) { + .rr-header__actions { + width: 100%; + } + + .rr-date { + flex: 1 1 100%; + width: auto; + } +} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 4a6fe1a06..a7db00b1d 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -12,6 +12,7 @@ import { Routes, RouterModule } from '@angular/router'; /** Routing Imports */ import { Route } from '../core/route/route.service'; +import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service'; /** Custom Components */ import { ReportsComponent } from './reports.component'; @@ -84,12 +85,14 @@ const routes: Routes = [ { path: 'tds-interest', data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' }, + resolve: { tdsConfig: tdsConfigResolver }, loadComponent: () => import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent) }, { path: 'tds-remittance', data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' }, + resolve: { tdsConfig: tdsConfigResolver }, loadComponent: () => import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) }, diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts index d2f1315f5..979ab26b0 100644 --- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts +++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts @@ -287,7 +287,11 @@ export class GlSettingsDialogComponent implements OnInit { this.sponsorDDService .upsertGlMapping({ sponsorBankId: 0, - ddPayableGlAccountId: val.ddPayableGlAccountId, + // DD Payable is set per sponsor bank (Organization > Sponsor Banks) and is the only source + // DD posting reads, so this screen no longer owns it. We still round-trip whatever the row + // already had -- so an existing value is preserved -- but a tenant that never set it (NULL) + // now saves fine instead of hitting "Sponsor DD Payable GL account is required". + ddPayableGlAccountId: this.mapping?.ddPayableGlAccountId ?? null, chequeClearingGlAccountId: val.chequeClearingGlAccountId, cashGlAccountId: val.cashGlAccountId, chargesIncomeGlAccountId: val.chargesIncomeGlAccountId, diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index e811f563a..bb0c52a1e 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -172,7 +172,8 @@ export class SponsorDDService { upsertGlMapping(data: { sponsorBankId: number; - ddPayableGlAccountId: number; + // DD Payable GL is configured per sponsor bank now, not on this screen, so it is optional here. + ddPayableGlAccountId?: number | null; chequeClearingGlAccountId?: number; cashGlAccountId?: number; chargesIncomeGlAccountId?: number; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts index 66c862d59..32f43e8d3 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component.ts @@ -94,7 +94,9 @@ export class DepositRequestsComponent implements OnInit { const today = new Date(this.settingsService.businessDate); today.setHours(0, 0, 0, 0); this.maxDate = today; - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } load(): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts index 335c88610..043fa4536 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component.ts @@ -63,7 +63,7 @@ export class DisbursementRequestsComponent implements OnInit { fromDate: Date | null = null; toDate: Date | null = null; maxDate!: Date; - activePreset = 'today'; + activePreset = 'all'; loading = false; loaded = false; @@ -86,7 +86,9 @@ export class DisbursementRequestsComponent implements OnInit { const today = new Date(this.settingsService.businessDate); today.setHours(0, 0, 0, 0); this.maxDate = today; - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } load(): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts index 03f420097..06d17b53d 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -138,7 +138,7 @@ export class PendingClientEditsComponent implements OnInit { fromDate: Date | null = null; toDate: Date | null = null; maxDate!: Date; - activePreset = 'today'; + activePreset = 'all'; readonly datePresets = [ { key: 'today', label: 'Today' }, { key: 'yesterday', label: 'Yesterday' }, @@ -157,7 +157,9 @@ export class PendingClientEditsComponent implements OnInit { today.setHours(0, 0, 0, 0); this.maxDate = today; this.loadOptions(); - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } private loadOptions(): void { diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts index 9585f8e29..330d2b0ea 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component.ts @@ -87,7 +87,9 @@ export class WithdrawalRequestsComponent implements OnInit { const today = new Date(this.settingsService.businessDate); today.setHours(0, 0, 0, 0); this.maxDate = today; - this.applyPreset('today'); + // A pending request must never be hidden by date: submitted_at is wall-clock time, and the business + // date "today" can lag it, so opening on Today showed an empty queue. Open on All instead. + this.applyPreset('all'); } load(): void { diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index 27cd9f12e..9244384a1 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -24,6 +24,13 @@ + @if (loadError) { +
+ +
{{ loadError }}
+
+ } +
diff --git a/src/app/tds-reports/tds-interest-report.component.ts b/src/app/tds-reports/tds-interest-report.component.ts index 0796c1a17..41c4ef137 100644 --- a/src/app/tds-reports/tds-interest-report.component.ts +++ b/src/app/tds-reports/tds-interest-report.component.ts @@ -15,7 +15,7 @@ import * as ExcelJS from 'exceljs'; import { BranchScopeService } from '../core/branch-scope.service'; import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data'; import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { setTdsDeductees, TDS_FYS } from './tds-reports-data'; +import { setTdsDeductees, tdsFys } from './tds-reports-data'; import { TdsReportsService } from './tds-reports.service'; import { NEAR_THRESHOLD_BAND, @@ -41,7 +41,8 @@ export class TdsInterestReportComponent implements OnInit { private tdsService = inject(TdsReportsService); readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO'); - readonly financialYears = TDS_FYS; + /** Filled by the route resolver from Organization › TDS Configuration. */ + readonly financialYears = tdsFys(); readonly quarterLabel = QUARTER_LABEL; readonly nearBand = NEAR_THRESHOLD_BAND; readonly quarters: Quarter[] = [ @@ -52,7 +53,7 @@ export class TdsInterestReportComponent implements OnInit { ]; filter: TdsReportFilter = { - fy: TDS_FYS[0], + fy: tdsFys()[0] ?? '', branchCode: '', quarter: 0, band: '', @@ -93,6 +94,10 @@ export class TdsInterestReportComponent implements OnInit { loadError = ''; ngOnInit(): void { + if (!this.financialYears.length) { + this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.'; + return; + } // A branch login sees only its own book and cannot widen the scope. const own = this.scope.ownBranchCode; if (own) { @@ -116,20 +121,29 @@ export class TdsInterestReportComponent implements OnInit { next: (deductees) => { this.loading = false; setTdsDeductees(deductees); - this.summaryCacheKey = ''; - this.cachedSummary = null; + this.invalidateCaches(); }, error: (err: any) => { this.loading = false; setTdsDeductees([]); - this.summaryCacheKey = ''; - this.cachedSummary = null; + this.invalidateCaches(); this.loadError = err?.error?.defaultUserMessage || 'Could not load deposit interest. The figures below cannot be shown.'; } }); } + /** + * Both caches are keyed on the filter alone, so a fresh load must drop them together. Clearing + * only the summary left the KPIs counting the new data while the tables kept the empty list + * cached on the first render, before the load returned. + */ + private invalidateCaches(): void { + this.rowsCacheKey = ''; + this.summaryCacheKey = ''; + this.cachedSummary = null; + } + /** * A different year needs a different set of interest credits, so the year re-reads from the * platform rather than re-filtering the set already in hand. diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts index 5a1b9ce66..9a1892cf0 100644 --- a/src/app/tds-reports/tds-remittance-report.component.ts +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -14,7 +14,7 @@ import * as ExcelJS from 'exceljs'; import { BranchScopeService } from '../core/branch-scope.service'; import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, TDS_FYS } from './tds-reports-data'; +import { TDS_AS_AT, tdsFys } from './tds-reports-data'; import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute'; @Component({ @@ -30,11 +30,12 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro export class TdsRemittanceReportComponent { private scope = inject(BranchScopeService); - readonly financialYears = TDS_FYS; + /** Filled by the route resolver from Organization › TDS Configuration. */ + readonly financialYears = tdsFys(); readonly quarterLabel = QUARTER_LABEL; readonly asAt = TDS_AS_AT; - fy = TDS_FYS[0]; + fy = tdsFys()[0] ?? ''; exporting = false; /** diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts index f5c639c6e..a92ba644a 100644 --- a/src/app/tds-reports/tds-reports-data.ts +++ b/src/app/tds-reports/tds-reports-data.ts @@ -20,17 +20,17 @@ import { GL_BRANCHES } from '../gl-reports/gl-reports-data'; import { + configuredFys, Declaration, Form15Kind, InterestCredit, TdsAccountInput } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */ -export const TDS_FYS = [ - '2026-27', - '2025-26' -]; +/** The years the reports can show: every year configured on Organization › TDS Configuration. */ +export function tdsFys(): string[] { + return configuredFys(); +} /** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */ export const TDS_AS_AT = '2026-08-01'; @@ -206,7 +206,7 @@ export function tdsChallans(deductedByQuarter: Record = {}; - for (const fy of TDS_FYS) { + for (const fy of Object.keys(deductedByQuarter)) { const y = Number(fy.slice(0, 4)); const months: Record = { 1: `${y}-06`, diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts index b128afe9c..f3b16c811 100644 --- a/src/app/tds-reports/tds-reports.compute.spec.ts +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -7,7 +7,8 @@ */ import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data'; +import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; +import { TDS_AS_AT, depositDueDate, tdsDeductees, tdsFys } from './tds-reports-data'; import { NEAR_THRESHOLD_BAND, TdsReportFilter, @@ -19,6 +20,8 @@ import { tdsRemittanceRows } from './tds-reports.compute'; +installTdsConfigFixture(); + /** The complete year in the seed — the one a return would be filed for. */ const CLOSED_FY = '2025-26'; @@ -75,7 +78,7 @@ describe('the reports follow the Act that governed each year', () => { }); it('keeps the age-based threshold band in both years', () => { - for (const fy of TDS_FYS) { + for (const fy of tdsFys()) { const rows = tdsInterestRowsForFy(fy); expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); @@ -240,7 +243,7 @@ describe('remittance schedule', () => { }); describe('the 26Q reconciliation control', () => { - it.each(TDS_FYS)('balances for FY %s', (fy) => { + it.each(tdsFys())('balances for FY %s', (fy) => { const s = tds26qSummary(fy, TDS_AS_AT); // Deducted must equal deposited plus what is still within its due date. // Anything else means tax has gone missing between the ledger and the diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts index afaa47d2a..022aeb982 100644 --- a/src/app/tds-reports/tds-reports.compute.ts +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -27,6 +27,7 @@ import { declarationFormFor, formKindFor, fyOf, + fyTaxConfigVersion, isSeniorInFy, isTdsApplicable, isWellFormedPan, @@ -118,7 +119,8 @@ let rowCache: TdsInterestRow[] = []; export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { // Keyed on the data set as well as the year: the rows are memoised, and a fresh load would // otherwise keep serving the previous customer's figures under the same financial year. - const key = fy + '|' + tdsDeducteesVersion; + // The tax configuration too: an administrator's save must not be hidden behind rows built on the old rates. + const key = fy + '|' + tdsDeducteesVersion + '|' + fyTaxConfigVersion; if (rowCacheKey === key) { return rowCache; } diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts index b8d1f4b07..8856cc8b7 100644 --- a/src/app/tds-reports/tds-reports.service.ts +++ b/src/app/tds-reports/tds-reports.service.ts @@ -13,6 +13,7 @@ import { Observable, map } from 'rxjs'; /** Custom Helpers */ import { toIsoDate } from '../core/utils/api-date'; +import { configuredFys } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { TdsDeductee } from './tds-reports-data'; /** @@ -77,14 +78,11 @@ export class TdsReportsService { return {}; } const out: Record = {}; - // Applied to the years the reports can show. Without an acceptance date on the record the - // declaration is treated as operative from the start of each year rather than mid-year. - for (const year of [ - 2025, - 2026 - ]) { - const fy = `FY ${year}-${String(year + 1).slice(2)}`; - out[fy] = { kind, acceptedOn: `${year}-04-01`, fy }; + // Applied to every configured year. Without an acceptance date on the record the declaration is + // treated as operative from the start of each year rather than mid-year. Keyed '2026-27', the + // form the computation looks it up by - an 'FY 2026-27' key was never found. + for (const fy of configuredFys()) { + out[fy] = { kind, acceptedOn: `${fy.slice(0, 4)}-04-01`, fy }; } return out; } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index f08a6bab3..44f95bdbb 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260923', - 'hash': '3f59cc6b' + 'version': '260924', + 'hash': 'a6e62201' }, 'allow_switching_backend_instance': true };