Merge branch 'dev-latest' into feature/bugfix

This commit is contained in:
2026-09-25 11:06:16 +05:30
70 changed files with 5600 additions and 565 deletions
@@ -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
@@ -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 }
},
{
@@ -55,7 +55,10 @@
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>
@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 {
@@ -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);
+13 -1
View File
@@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.method !== 'GET') {
if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) {
return next.handle(request);
}
+22
View File
@@ -2071,6 +2071,28 @@
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital
</a>
</div>
<!-- Reserve Returns — CRR and SLR, computed live from the ledger. Grouped apart from the
OSS returns because they are filed on their own cycles: Form 1 twice a month, Form 9
at month end. -->
<div class="gl-group">
<div class="gl-group-title">Reserve Returns</div>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 1 CRR Cash Reserve Ratio')"
routerLink="/rbi/form1"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 1 — CRR (fortnightly)
</a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 9 SLR Statutory Liquidity Ratio')"
routerLink="/rbi/form9"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 9 — SLR (monthly)
</a>
</div>
<!-- CIMS / XBRL Returns -->
<div class="gl-group">
<div class="gl-group-title">CIMS / XBRL Returns</div>
@@ -8,7 +8,10 @@
<div class="cur-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
<!-- Shown only when the account's product has Dormancy Alert = Yes -->
@if (currentAccountData?.dormancyAlert) {
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
}
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="cur-hero">
@@ -59,16 +59,7 @@
<span class="nx-mono">[{{ currentAccountData.currency.code }}]</span>
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@if (currentAccountData.nominalAnnualInterestRate) {
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
} @else {
0 %
}
</span>
</div>
<!-- A current account earns no interest, so no interest rate is shown here; OD interest is under Overdraft. -->
} @else {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
@@ -102,17 +93,17 @@
</span>
</div>
}
@if (currentAccountData.summary.totalInterestEarned >= 0) {
@if (currentAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (!currentAccountData.summary.totalDeposits && !(currentAccountData.summary.totalInterestEarned >= 0)) {
@if (!currentAccountData.summary.totalDeposits && !currentAccountData.summary.totalWithdrawals) {
<div class="nx-empty">
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
<span>No activity yet.</span>
@@ -132,16 +123,7 @@
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<div class="nx-card__body">
@if (currentAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
<!-- A current account earns no interest: no interest earned/posted rows. OD interest has its own card. -->
@if (currentAccountData.summary.totalWithdrawalFees) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
@@ -160,89 +142,6 @@
</span>
</div>
}
@if (currentAccountData.summary.totalInterestEarned >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.totalInterestPosted) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.interestNotPosted >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.totalOverdraftInterestDerived) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest On Overdraft' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalOverdraftInterestDerived
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.interestNotPosted < 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.nominalAnnualInterestRate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Compounding Period' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Calculated Using' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
</span>
</div>
@if (currentAccountData.withdrawalFee) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
@@ -289,37 +188,6 @@
</span>
</div>
}
@if (currentAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Limit' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (currentAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{
currentAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.minBalanceForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{ currentAccountData.minBalanceForInterestCalculation | formatNumber }}
</span>
</div>
}
@if (currentAccountData.minRequiredBalance) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span>
@@ -332,14 +200,6 @@
<span class="nx-row__value">{{ currentAccountData.enforceMinRequiredBalance | yesNo }}</span>
</div>
}
@if (currentAccountData.summary.lastInterestCalculationDate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Recalculation Date' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.summary.lastInterestCalculationDate | dateFormat }}
</span>
</div>
}
@if (currentAccountData.onHoldFunds) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
@@ -350,14 +210,72 @@
</span>
</div>
}
@if (currentAccountData.withHoldTax) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withhold Tax Group' | translate }}</span>
<span class="nx-row__value">{{ currentAccountData.taxGroup.name }}</span>
</div>
}
</div>
</section>
<!-- Overdraft: the only interest on a current account is what the customer pays on an OD. -->
@if (currentAccountData.allowOverdraft) {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="hand-holding-usd" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">Overdraft (OD)</h3>
</header>
<div class="nx-card__body">
<div class="nx-row">
<span class="nx-row__label">Maximum OD Limit (per product)</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
@if (currentAccountData.nominalAnnualInterestRateOverdraft) {
<div class="nx-row">
<span class="nx-row__label">OD Interest Rate</span>
<span class="nx-row__value">
{{ currentAccountData.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.
</span>
</div>
}
@if (currentAccountData.minOverdraftForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">Minimum OD for Interest</span>
<span class="nx-row__value">
{{
currentAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">OD Interest Posting</span>
<span class="nx-row__value">
{{ currentAccountData.interestPostingPeriodType?.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationDaysInYearType?.value | translateKey: 'catalogs' }}
</span>
</div>
@if (currentAccountData.validForPeriod) {
<div class="nx-row">
<span class="nx-row__label">Sanction Validity</span>
<span class="nx-row__value">{{ currentAccountData.validForPeriod }}</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">OD Interest Charged</span>
<span class="nx-row__value nx-row__value--amount">
{{
(currentAccountData.summary.totalOverdraftInterestDerived || 0)
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
</div>
</section>
}
} @else {
<section class="nx-card">
<header class="nx-card__head">
@@ -45,6 +45,44 @@
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
{{ dashboardRenewalTimingText }}
</p>
@if (renewalPreviewLoading) {
<p class="pmt__hint">
<fa-icon icon="spinner" [class.fa-spin]="true" class="m-r-5"></fa-icon>
Calculating gap-period interest...
</p>
} @else if (renewalPreview && !renewalPreview.withinGrace) {
@if (!renewalPreview.linkedAccountId) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This FD has no Deposit Account Link, so there is no account to read a gap-interest rate from. Link a
Savings account to it before renewing late.
</p>
} @else {
<div class="pmt__preview">
<div class="pmt__preview-row">
<span class="pmt__preview-label">Current Balance</span>
<span class="pmt__preview-value">{{ renewalPreview.currentBalance | formatNumber }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Gap Days ({{ renewalPreview.maturityDate }} &rarr; today)</span>
<span class="pmt__preview-value">{{ renewalPreview.gapDays }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Savings Account Interest Rate</span>
<span class="pmt__preview-value">{{ renewalPreview.linkedAccountRate }}%</span>
</div>
<div class="pmt__preview-row pmt__preview-row--highlight">
<span class="pmt__preview-label">Gap-Period Interest to be Credited</span>
<span class="pmt__preview-value">+ {{ renewalPreview.gapInterestAmount | formatNumber }}</span>
</div>
<div class="pmt__preview-row pmt__preview-row--total">
<span class="pmt__preview-label">New Balance for the Renewed Term</span>
<span class="pmt__preview-value">{{ renewalPreview.projectedBalance | formatNumber }}</span>
</div>
</div>
}
}
}
@if (reinvestCap !== null && reinvestCap !== undefined) {
@@ -164,6 +164,68 @@
}
}
.pmt__preview {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 4px 18px;
margin: 0 0 18px;
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&--highlight {
.pmt__preview-value {
color: #0d9488;
font-weight: 800;
}
}
&--total {
margin: 0 -18px;
padding: 14px 18px;
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border-bottom: none;
border-radius: 0 0 12px 12px;
.pmt__preview-label {
color: #1d4ed8;
font-weight: 700;
}
.pmt__preview-value {
color: #0f172a;
font-size: 18px;
font-weight: 800;
}
}
}
&-label {
font-size: 12.5px;
color: #64748b;
font-weight: 600;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
}
.pmt__section-label {
font-size: 12px;
font-weight: 700;
@@ -113,6 +113,12 @@ export class PaymentDialogComponent implements OnInit {
* after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
private gapInterestPending = false;
/** Read-only numbers (current balance, linked account's rate, gap days, gap-interest amount,
* projected new balance) fetched once on open in dashboard-renewal mode, shown above the Action
* cards so staff see exactly what a late renewal is about to credit before confirming anything. */
renewalPreview: any = null;
renewalPreviewLoading = false;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
@@ -136,6 +142,19 @@ export class PaymentDialogComponent implements OnInit {
.get('closedOnDate')
.setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate);
this.gapInterestPending = !dashboardRow.withinGraceWindow;
this.renewalPreviewLoading = true;
this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
next: (preview: any) => {
this.renewalPreview = preview;
this.renewalPreviewLoading = false;
},
// Non-fatal -- the plain timing text above still explains what will happen even if the
// numeric breakdown couldn't be fetched; only the numbers are missing, not the action itself.
error: () => {
this.renewalPreviewLoading = false;
}
});
}
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
@@ -174,6 +174,13 @@ export class FixedDepositsService {
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
}
/** Read-only -- what a Renew is about to do (current balance, linked account's rate, gap days,
* gap-interest amount, projected new balance), shown in the Payment dialog before anything is
* confirmed. Posts nothing; safe to call as often as the dialog needs. */
getFdRenewalPreview(accountId: string | number): Observable<any> {
return this.http.get(`/fixeddepositaccounts/${accountId}/renewal-preview`);
}
/**
* @param fixedDepositAccount Fixed Deposit Account
* @returns {Observable<any>}
@@ -181,15 +181,15 @@
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.accountId) {
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="selected.has(r.accountId)">
@for (r of visibleRows; track r.accountType + '-' + r.accountId) {
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="isSelected(r)">
@if (tab === 'ESCHEAT') {
<td class="dr-tick">
<!-- An already-remitted balance cannot be remitted again. -->
<input
type="checkbox"
[checked]="selected.has(r.accountId)"
[disabled]="r.remittance === 'REMITTED'"
[checked]="isSelected(r)"
[disabled]="!isRemittable(r)"
(click)="toggleSelect(r, $event)"
/>
</td>
@@ -203,7 +203,12 @@
<td>{{ branchName(r.branchCode) }}</td>
<td class="dr-prod">{{ r.productName }}</td>
<td class="dr-prod">
{{ r.productName }}
@if (r.accountType === 'CURRENT') {
<div class="dr-sub">Current Account</div>
}
</td>
<td class="num strong">{{ r.balance | number: '1.0-0' }}</td>
@@ -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,
@@ -80,6 +80,8 @@ export const DORMANCY_STAGES: Record<DormancyStage, DormancyStageMeta> = {
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;
@@ -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'
};
}
@@ -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<string, Observable<any>>();
private clientKycCache = new Map<string, Observable<any>>();
private clientAccountsCache = new Map<string, Observable<any>>();
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();
@@ -30,6 +30,9 @@ import {
tdsDeducteeOf,
tdsModeFor
} from './tds-15gh.compute';
import { installTdsConfigFixture } from './tds-config.fixture';
installTdsConfigFixture();
const FY = 'FY 2026-27';
@@ -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<string, FyTaxConfig> = {
'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<string, FyTaxConfig> = {};
/** 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 ───────────────────────
@@ -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);
}
@@ -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<TdsConfigResponse> {
return this.http.get<TdsConfigResponse>('/tdsconfig').pipe(tap((res) => this.install(res)));
}
/** For route resolvers: never blocks navigation, the screen reports a missing year itself. */
ensureLoaded(): Observable<boolean> {
return this.load().pipe(
map(() => true),
catchError(() => {
setFyTaxConfigs([]);
return of(false);
})
);
}
template(): Observable<any> {
return this.http.get('/tdsconfig/template');
}
saveYear(record: TdsFyConfigRecord): Observable<TdsConfigResponse> {
return this.http
.put<TdsConfigResponse>(`/tdsconfig/year/${record.fy}`, record)
.pipe(tap((res) => this.install(res)));
}
deleteYear(fy: string): Observable<TdsConfigResponse> {
return this.http.delete<TdsConfigResponse>(`/tdsconfig/year/${fy}`).pipe(tap((res) => this.install(res)));
}
saveGl(configKey: string, glAccountId: number | null): Observable<TdsConfigResponse> {
return this.http
.put<TdsConfigResponse>(`/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<boolean> = () => inject(TdsConfigService).ensureLoaded();
@@ -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' }
},
{
@@ -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' },
@@ -76,6 +76,64 @@
</a>
</div>
<!-- RBI Reserve Configuration -->
<div #rbiReserveConfig>
<a class="org-card" [routerLink]="['rbi-reserve-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-percent"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">RBI Reserve Configuration</h3>
<p class="org-card__desc">CRR and SLR rates, and the GL accounts behind Form 1 and Form 9</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- TDS Configuration -->
<div>
<a class="org-card" [routerLink]="['tds-config']" *mifosxHasPermission="'READ_TDSCONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-receipt"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">TDS Configuration</h3>
<p class="org-card__desc">TDS thresholds and rates per financial year, and the TDS payable GL</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- OSS 1 GL Configuration -->
<div #oss1GlConfig>
<a class="org-card" [routerLink]="['oss1-gl-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-scale-balanced"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">OSS 1 GL Configuration</h3>
<p class="org-card__desc">
GL accounts behind OSS 1 — Return on Assets and Liabilities, read as closing balances
</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- OSS 2 GL Configuration -->
<div #oss2GlConfig>
<a class="org-card" [routerLink]="['oss2-gl-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-chart-line"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">OSS 2 GL Configuration</h3>
<p class="org-card__desc">GL accounts behind OSS 2 — Return on Earnings, read as quarterly movement</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- Currency Configuration -->
<div #addEditCurrency>
<a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'">
@@ -935,4 +935,71 @@ export class OrganizationService {
updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable<any> {
return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId });
}
// ── RBI Reserve Configuration (Form 1 CRR / Form 9 SLR) ──
/**
* @returns {Observable<any>} `{ 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<any> {
return this.http.get('/rbireserveconfig');
}
/** @returns {Observable<any>} Selectable (postable, non-header) GL accounts plus the rate types. */
getRbiReserveConfigTemplate(): Observable<any> {
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<any> {
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<any> {
return this.http.post('/rbireserveconfig/role', { ...role, locale: 'en' });
}
/** Removes an administrator-defined head and the accounts pinned to it. */
deleteRbiReserveRole(roleId: number): Observable<any> {
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<any> {
return this.http.post('/rbireserveconfig/rate', {
rateType,
percentage,
effectiveFrom,
locale: 'en',
dateFormat: 'yyyy-MM-dd'
});
}
/** @returns {Observable<any>} */
deleteRbiReserveRate(rateId: number): Observable<any> {
return this.http.delete(`/rbireserveconfig/rate/${rateId}`);
}
}
@@ -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.'
);
}
}
@@ -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.'
);
}
}
@@ -0,0 +1,336 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="rrc-container">
<div class="rrc-header">
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="rrc-header__title">{{ screenTitle }}</h2>
</div>
<p class="rrc-sub">{{ screenSubtitle }}</p>
@if (errorMsg) {
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="rrc-loading">Loading…</div>
} @else {
<!-- ═══ Rates — only the reserve returns are charged at a percentage ═══ -->
@if (showRates) {
<div class="rrc-card">
<h3 class="rrc-card__title">Reserve rates</h3>
<p class="rrc-card__hint">
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.
</p>
<div class="rrc-rate-now">
@for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) {
<div class="rrc-rate-now__item">
<span class="rrc-rate-now__label">
{{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today
</span>
@if (currentRate(type); as rate) {
<span class="rrc-rate-now__value">{{ rate.percentage }}%</span>
@if (rate.seedDefault) {
<span class="rrc-rate-now__since rrc-rate-now__since--warn">
Placeholder — confirm the rate actually in force
</span>
} @else {
<span class="rrc-rate-now__since">since {{ rate.effectiveFrom }}</span>
}
} @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
}
</div>
}
</div>
<div class="rrc-rate-form">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
savings balances that counts as a demand liability, the rest being time. -->
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__pct">
<mat-label>Percentage (%)</mat-label>
<input matInput type="number" step="0.0001" min="0" max="100" [(ngModel)]="newRate.percentage" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__date">
<mat-label>Effective from</mat-label>
<input matInput type="date" [(ngModel)]="newRate.effectiveFrom" />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="savingRate" (click)="saveRate()">
{{ savingRate ? 'Saving…' : 'Save rate' }}
</button>
</div>
<table class="rrc-table">
<thead>
<tr>
<th>Rate</th>
<th>Percentage</th>
<th>Effective from</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (rate of rates; track rate.id) {
<tr>
<td><span class="rrc-key">{{ rate.rateType }}</span></td>
<td class="rrc-num">{{ rate.percentage }}%</td>
<td>{{ rate.effectiveFrom }}</td>
<td>
@if (isFuture(rate)) {
<span class="rrc-badge rrc-badge--future">Future-dated</span>
} @else if (currentRate(rate.rateType)?.id === rate.id) {
<span class="rrc-badge rrc-badge--active">In force</span>
} @else {
<span class="rrc-badge rrc-badge--past">Superseded</span>
}
@if (rate.seedDefault) {
<span class="rrc-badge rrc-badge--seed">Placeholder</span>
}
</td>
<td>
<button mat-button class="rrc-remove" (click)="deleteRate(rate)" title="Remove this rate">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ═══ GL accounts ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<p class="rrc-card__hint">
Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each
one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of
its children, so mapping both would count those children twice.
</p>
@if (unmappedCount) {
<div class="rrc-alert rrc-alert--warn">
{{ 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.
</div>
}
<!--
One tab per return. The roles of four returns on a single list would bury the four that
drive CRR among the thirty that do not, so each return's roles are configured on their own.
Each tab carries its own unmapped count, so a return that cannot compute is visible without
opening its tab.
-->
@if (showTabs) {
<div class="rrc-tabs" role="tablist">
@for (group of roleGroups; track group.name) {
<button
type="button"
role="tab"
class="rrc-tab"
[class.rrc-tab--active]="group.name === activeGroup"
[attr.aria-selected]="group.name === activeGroup"
(click)="showGroup(group.name)"
>
{{ group.label }}
@if (unmappedIn(group)) {
<span class="rrc-tab__badge">{{ unmappedIn(group) }} unmapped</span>
} @else {
<span class="rrc-tab__badge rrc-tab__badge--done">all mapped</span>
}
</button>
}
</div>
}
@for (role of visibleRoles; track role.configKey) {
<div class="rrc-role" [class.rrc-role--unmapped]="isUnmapped(role)">
<div class="rrc-role__head">
<div>
<span class="rrc-role__name">{{ roleLabel(role) }}</span>
<span class="rrc-key">{{ role.configKey }}</span>
<span class="rrc-role__usedby">{{ roleUsedBy(role) }}</span>
</div>
@if (isUnmapped(role)) {
<span class="rrc-badge rrc-badge--unmapped">Not mapped</span>
} @else {
<span class="rrc-badge rrc-badge--mapped">
{{ selection[role.configKey].length }} account{{ selection[role.configKey].length === 1 ? '' : 's' }}
</span>
}
</div>
<p class="rrc-role__desc">{{ role.description }}</p>
<!--
One row per mapping rather than a multi-select, because a mapping is no longer just an
account: it carries the portion of the balance this head takes, and which head that
portion falls under. A savings account appears twice here - a demand slice and a time
slice - which a multi-select cannot express.
-->
@if (selection[role.configKey]?.length) {
<table class="rrc-map">
<thead>
<tr>
<th>GL account</th>
<th class="rrc-map__narrow">Portion (%)</th>
@if (role.classified) {
<th class="rrc-map__narrow">Head</th>
}
<th class="rrc-map__actions"></th>
</tr>
</thead>
<tbody>
@for (mapping of selection[role.configKey]; track $index; let i = $index) {
<tr [class.rrc-map__row--invalid]="isRowInvalid(mapping)">
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.glAccountId" [disabled]="savingKey === role.configKey">
@for (o of availableAccounts(role, i); track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<!-- The share of this account's balance the head takes. 100 is the whole of it,
which is what every mapping meant before splits existed. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<input
matInput
type="number"
min="0"
max="100"
step="0.0001"
[(ngModel)]="mapping.weightPercentage"
[disabled]="savingKey === role.configKey"
/>
</mat-form-field>
</td>
@if (role.classified) {
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.liabilityClass" [disabled]="savingKey === role.configKey">
@for (c of liabilityClasses; track c.value) {
<mat-option [value]="c.value">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
}
<td class="rrc-map__actions">
<button mat-button class="rrc-remove" (click)="removeMapping(role, i)" title="Remove this row">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
<!--
An account split across rows whose portions do not add to the whole balance is the one
error a split invites and no single row shows: each row looks reasonable while a share of
the balance silently leaves the base.
-->
@for (entry of unbalancedAccounts(role); track entry.glAccountId) {
<div class="rrc-alert rrc-alert--warn rrc-alert--inline">
{{ 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.
}
</div>
}
<div class="rrc-role__controls">
<button mat-stroked-button (click)="addMapping(role)" [disabled]="savingKey === role.configKey">
<i class="fas fa-plus"></i> Add account
</button>
<button
mat-raised-button
color="primary"
[disabled]="savingKey === role.configKey || !isDirty(role) || hasInvalidRows(role)"
(click)="saveRole(role)"
>
{{ savingKey === role.configKey ? 'Saving…' : 'Save' }}
</button>
@if (!role.builtIn) {
<button mat-button class="rrc-remove" (click)="deleteRole(role)" title="Remove this head">
<i class="fas fa-trash"></i> Remove head
</button>
}
</div>
</div>
}
<!-- ═══ add a head ═══ -->
<div class="rrc-add">
@if (!showAddRole) {
<button mat-stroked-button (click)="showAddRole = true">
<i class="fas fa-plus"></i> Add a head to this return
</button>
<p class="rrc-card__hint">
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.
</p>
} @else {
<div class="rrc-add__form">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Name</mat-label>
<input matInput [(ngModel)]="newRole.label" placeholder="e.g. Reserve Fund" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>How the return uses it</mat-label>
<mat-select [(ngModel)]="newRole.usage">
@for (u of roleUsages; track u.value) {
<mat-option [value]="u.value">{{ u.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-add__desc">
<mat-label>Note (optional)</mat-label>
<input matInput [(ngModel)]="newRole.description" placeholder="What this head covers" />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="addingRole" (click)="addRole()">
{{ addingRole ? 'Adding…' : 'Add head' }}
</button>
<button mat-button (click)="showAddRole = false">Cancel</button>
</div>
}
</div>
</div>
}
</div>
@@ -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;
}
}
@@ -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<string, ReserveMapping[]> = {};
/** 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<string, { label: string; roles: any[] }>();
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;
}
}
@@ -0,0 +1,216 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="rrc-container">
<div class="rrc-header">
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="rrc-header__title">TDS Configuration</h2>
</div>
<p class="rrc-sub">
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.
</p>
@if (errorMsg) {
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="rrc-loading">Loading…</div>
} @else {
<!-- ═══ GL ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL account</h3>
<p class="rrc-card__hint">
Only postable liability accounts are listed. Nothing is preselected: the right account differs per chart of
accounts.
</p>
@for (role of glRoles; track role.configKey) {
<div class="rrc-role" [class.rrc-role--unmapped]="!role.glAccountId">
<div class="rrc-role__head">
<div>
<span class="rrc-role__name">{{ role.label }}</span>
<span class="rrc-key">{{ role.configKey }}</span>
</div>
@if (role.glAccountId) {
<span class="rrc-badge rrc-badge--mapped">{{ role.glCode }} · {{ role.glAccountName }}</span>
} @else {
<span class="rrc-badge rrc-badge--unmapped">Not mapped</span>
}
</div>
<p class="rrc-role__desc">{{ role.description }}</p>
<div class="tdc-gl">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="tdc-gl__field">
<mat-label>GL account</mat-label>
<mat-select [(ngModel)]="glSelection[role.configKey]" [disabled]="savingGl">
<mat-option [value]="null">— Not mapped —</mat-option>
@for (o of glAccountOptions; track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
[disabled]="savingGl || !isGlDirty(role)"
(click)="saveGl(role)"
>
{{ savingGl ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
}
</div>
<!-- ═══ Financial years ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">Financial years</h3>
<p class="rrc-card__hint">
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting
that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
</p>
<div class="rrc-tabs" role="tablist">
@for (y of years; track y.fy) {
<button
type="button"
role="tab"
class="rrc-tab"
[class.rrc-tab--active]="!isNew && draft?.fy === y.fy"
(click)="select(y)"
>
FY {{ y.fy }}
@if (y.seedDefault) {
<span class="rrc-tab__badge">confirm</span>
}
</button>
}
@if (isNew) {
<button type="button" role="tab" class="rrc-tab rrc-tab--active">New year</button>
} @else {
<button type="button" class="rrc-tab" (click)="startNewYear()">
<i class="fas fa-plus"></i> Add financial year
</button>
}
</div>
@if (draft) {
@if (draft.seedDefault && !isNew) {
<div class="rrc-alert rrc-alert--warn">
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.
</div>
}
@if (isNew) {
<div class="tdc-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Financial year</mat-label>
<input matInput [(ngModel)]="draft.fy" placeholder="2027-28" />
@if (!fyValid) {
<mat-hint>Format: 2027-28</mat-hint>
} @else if (fyTaken) {
<mat-hint>FY {{ draft.fy }} is already configured</mat-hint>
}
</mat-form-field>
</div>
}
<h4 class="tdc-section">Thresholds &amp; rates</h4>
<div class="tdc-grid">
@for (f of rateFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
<input
matInput
type="number"
[min]="0"
[max]="f.kind === 'percent' ? 100 : null"
[step]="f.kind === 'percent' ? 0.0001 : 1"
[(ngModel)]="$any(draft)[f.key]"
/>
@if (f.hint) {
<mat-hint>{{ f.hint }}</mat-hint>
}
</mat-form-field>
}
</div>
@if (noPanBelowRate) {
<div class="rrc-alert rrc-alert--warn">The rate without PAN cannot be lower than the rate with PAN.</div>
}
<h4 class="tdc-section">Declaration (Form 121 / 15G / 15H)</h4>
<div class="tdc-grid">
@for (f of declarationFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
@if (f.kind === 'select') {
<mat-select [(ngModel)]="$any(draft)[f.key]">
@for (o of f.options; track o.value) {
<mat-option [value]="o.value">{{ o.label }}</mat-option>
}
</mat-select>
} @else {
<input matInput type="number" [min]="0" [(ngModel)]="$any(draft)[f.key]" />
}
@if (f.hint) {
<mat-hint>{{ f.hint }}</mat-hint>
}
</mat-form-field>
}
</div>
<h4 class="tdc-section">Statutory references</h4>
<p class="rrc-card__hint">Printed on the reports, certificates and returns for this year.</p>
<div class="tdc-grid">
@for (f of statuteFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
@if (f.kind === 'select') {
<mat-select [(ngModel)]="$any(draft)[f.key]">
@for (o of f.options; track o.value) {
<mat-option [value]="o.value">{{ o.label }}</mat-option>
}
</mat-select>
} @else {
<input matInput [(ngModel)]="$any(draft)[f.key]" />
}
</mat-form-field>
}
</div>
<div class="rrc-role__controls">
<button mat-raised-button color="primary" [disabled]="!canSave" (click)="save()">
{{ saving ? 'Saving…' : isNew ? 'Add FY ' + draft.fy : 'Save FY ' + draft.fy }}
</button>
@if (isNew) {
<button mat-button (click)="cancelNew()">Cancel</button>
} @else if (years.length > 1) {
<button mat-button class="rrc-remove" (click)="deleteYear()">
<i class="fas fa-trash"></i> Remove FY {{ draft.fy }}
</button>
}
@if (draft.updatedOn && !isNew) {
<span class="rrc-card__hint">Last saved {{ draft.updatedOn | slice: 0 : 16 }}</span>
}
</div>
} @else {
<div class="rrc-alert rrc-alert--warn">
No financial year is configured. Every TDS screen is unable to compute until one is added.
</div>
}
</div>
}
</div>
@@ -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;
}
}
@@ -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<string, number | null> = {};
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;
}
}
@@ -50,6 +50,7 @@
<div class="cp-tab-content">
<mifosx-current-product-terms-step
[CurrentProductsTemplate]="currentProductsTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step>
</div>
</mat-tab>
@@ -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',
@@ -162,29 +162,16 @@
}}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Days in Year:</span>
<span class="flex-60">{{ CurrentProduct.interestCalculationDaysInYearType }} Days</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ CurrentProduct.autoPost | yesNo }}</span>
</div>
@if (
CurrentProduct.frequencyFixDay !== undefined &&
CurrentProduct.frequencyFixDay !== null &&
CurrentProduct.frequencyFixDay !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency (Fix Day):</span>
<span class="flex-60">{{ CurrentProduct.frequencyFixDay }} Days</span>
</div>
}
@if (CurrentProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ CurrentProduct.lastInterestDate | dateFormat }}</span>
</div>
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@@ -282,39 +269,58 @@
</div>
@if (CurrentProduct.allowOverdraft) {
@if (CurrentProduct.avgBalanceCalcPeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Avg Balance Calculation Period:</span>
<span class="flex-60">{{ CurrentProduct.avgBalanceCalcPeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Maximum OD Limit:</span>
<span class="flex-60">₹ {{ CurrentProduct.overdraftLimit | formatNumber }}</span>
</div>
@if (CurrentProduct.validForPeriod) {
@if (CurrentProduct.odMinLimit) {
<div class="flex-100 layout-row">
<span class="flex-40">Valid for Period:</span>
<span class="flex-60">{{ CurrentProduct.validForPeriod }}</span>
</div>
}
@if (CurrentProduct.overdraftLimit !== undefined && CurrentProduct.overdraftLimit !== '') {
<div class="flex-100 layout-row">
<span class="flex-40">OD Limit %:</span>
<span class="flex-60">{{ CurrentProduct.overdraftLimit }}</span>
</div>
}
@if (
CurrentProduct.nominalAnnualInterestRateOverdraft !== undefined &&
CurrentProduct.nominalAnnualInterestRateOverdraft !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Interest on OD %:</span>
<span class="flex-60">{{ CurrentProduct.nominalAnnualInterestRateOverdraft }}</span>
<span class="flex-40">Minimum OD Limit:</span>
<span class="flex-60">₹ {{ CurrentProduct.odMinLimit | formatNumber }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">OD Account Closed with Zero Balance in Day Closing:</span>
<span class="flex-40">OD Security Type:</span>
<span class="flex-60">{{ CurrentProduct.odSecurityType | titlecase }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Sanction Validity:</span>
<span class="flex-60">{{ CurrentProduct.validForPeriod }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Excess Drawing Allowed:</span>
<span class="flex-60">{{ CurrentProduct.odExcessAllowed | yesNo }}</span>
</div>
@if (CurrentProduct.odExcessAllowed) {
<div class="flex-100 layout-row">
<span class="flex-40">Maximum Excess:</span>
<span class="flex-60">{{ CurrentProduct.odExcessLimitPercent }} % of limit</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Penal Charge on Excess:</span>
<span class="flex-60">{{ CurrentProduct.odPenalRate }} % p.a.</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">OD Interest Rate:</span>
<span class="flex-60">{{ CurrentProduct.nominalAnnualInterestRateOverdraft }} % p.a.</span>
</div>
@if (CurrentProduct.minOverdraftForInterestCalculation) {
<div class="flex-100 layout-row">
<span class="flex-40">Minimum OD for Interest:</span>
<span class="flex-60">₹ {{ CurrentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">End OD Once Fully Repaid:</span>
<span class="flex-60">{{ CurrentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
}
@@ -453,15 +459,6 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Assets</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings reference account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.savingsReferenceAccountId,
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{
@@ -503,7 +500,7 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Liabilities</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings control account</span>
<span class="spa-gl-row__label">Current control account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.savingsControlAccountId,
@@ -511,15 +508,6 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Transfers in suspense account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.transfersInSuspenseAccountId,
CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
@@ -542,28 +530,6 @@
}
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Expenses</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest on savings account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.interestOnSavingsAccountId,
CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Write off account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.writeOffAccountId,
CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Income</h5>
<div class="spa-gl-row">
@@ -110,72 +110,126 @@
<span>Overdraft (OD) Facility</span>
</div>
<div class="sps-body">
<!-- "Is Overdraft Allowed" is ticked on the Terms tab; this section follows it. -->
@if (!CurrentProductSettingsForm.value.allowOverdraft) {
<p class="sps-od-check-hint">
Overdraft is not allowed on this product. Tick "Is Overdraft Allowed" on the Terms tab to set the OD limits.
</p>
}
<div class="sps-od-layout">
<!-- Left: checkboxes -->
<div class="sps-od-checks">
<div class="sps-od-check-row">
<mat-checkbox formControlName="allowOverdraft">
<span>Is Overdraft Allowed</span>
</mat-checkbox>
</div>
@if (CurrentProductSettingsForm.value.allowOverdraft) {
<div class="sps-od-check-row">
<mat-checkbox formControlName="odAccountClosedZeroBalanceInDayClosing">
<span>OD Account Closed with Zero Balance in Day Closing</span>
<span>End OD Once Fully Repaid (One-time OD only)</span>
</mat-checkbox>
<p class="sps-od-check-hint">
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.
</p>
</div>
}
</div>
<!-- Right: OD details panel — only visible when OD is allowed -->
<!-- Right: the OD scheme every account's sanction is held to — only visible when OD is allowed -->
@if (CurrentProductSettingsForm.value.allowOverdraft) {
<div class="sps-od-panel">
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Average Balance Calculation in Period</span>
<mat-radio-group formControlName="avgBalanceCalcPeriod" class="sps-od-radio-choices">
<mat-radio-button value="6M">6M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Valid for Period</span>
<mat-radio-group formControlName="validForPeriod" class="sps-od-radio-choices">
<mat-radio-button value="3M">3M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-inputs">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>OD Limit %</mat-label>
<mat-label>Maximum OD Limit (₹)</mat-label>
<input
type="number"
matInput
formControlName="overdraftLimit"
placeholder="e.g. 15"
min="0"
step="0.01"
placeholder="e.g. 500000"
min="1"
required
/>
<mat-hint>Highest per account</mat-hint>
<mat-error>Enter the maximum OD limit</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Interest on OD %</mat-label>
<input
type="number"
matInput
formControlName="nominalAnnualInterestRateOverdraft"
placeholder="e.g. 8.5"
min="0"
step="0.01"
/>
<mat-label>Minimum OD Limit (₹)</mat-label>
<input type="number" matInput formControlName="odMinLimit" placeholder="e.g. 10000" min="0" />
<mat-hint>Lowest per account</mat-hint>
@if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) {
<mat-error>Cannot exceed the maximum OD limit</mat-error>
} @else {
<mat-error>Cannot be negative</mat-error>
}
</mat-form-field>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">OD Security Type</span>
<mat-radio-group formControlName="odSecurityType" class="sps-od-radio-choices">
@for (option of odSecurityTypeOptions; track option.value) {
<mat-radio-button [value]="option.value">{{ option.label }}</mat-radio-button>
}
</mat-radio-group>
<p class="sps-od-check-hint">{{ selectedSecurityTypeHint }}</p>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Sanction Validity</span>
<mat-radio-group formControlName="validForPeriod" class="sps-od-radio-choices">
@for (option of odValidityOptions; track option.value) {
<mat-radio-button [value]="option.value">{{ option.label }}</mat-radio-button>
}
</mat-radio-group>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Excess Drawing (Over Limit)</span>
<mat-checkbox formControlName="odExcessAllowed">
<span>Allow withdrawal beyond the sanctioned limit</span>
</mat-checkbox>
@if (!CurrentProductSettingsForm.value.odExcessAllowed) {
<p class="sps-od-check-hint">Off: a withdrawal that would cross the limit is refused.</p>
} @else {
<div class="sps-od-inputs">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Maximum Excess (% of limit)</mat-label>
<input
type="number"
matInput
formControlName="odExcessLimitPercent"
placeholder="e.g. 10"
min="0"
step="0.01"
required
/>
<mat-hint>Beyond this, refused</mat-hint>
<mat-error>Enter above 0% and up to 25%</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Penal Charge on Excess (% p.a.)</mat-label>
<input
type="number"
matInput
formControlName="odPenalRate"
placeholder="e.g. 2"
min="0"
step="0.01"
required
/>
<mat-hint>Only on the excess amount</mat-hint>
<mat-error>Enter above 0% and up to 50%</mat-error>
</mat-form-field>
</div>
@if (excessExample) {
<p class="sps-od-check-hint">{{ excessExample }}</p>
}
}
</div>
<p class="sps-od-check-hint">
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.
</p>
</div>
}
</div>
@@ -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;
@@ -13,20 +13,76 @@
<span>Interest On OD</span>
</div>
<div class="spt-body">
<mat-form-field appearance="outline" class="spt-field" style="max-width: 320px">
<mat-label>Interest Posting Frequency *</mat-label>
<mat-select formControlName="interestPostingPeriodType" required>
@for (item of interestPostingPeriodTypeData; track item) {
<mat-option [value]="item.id">
{{ item.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
Interest Posting Frequency {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (allowOverdraftControl) {
<div class="spt-toggle-card">
<mat-checkbox [formControl]="allowOverdraftControl">
<span>Is Overdraft Allowed</span>
</mat-checkbox>
</div>
}
@if (!isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span>
</div>
}
<div class="spt-footer__grid">
@if (isOverdraftAllowed) {
<mat-form-field appearance="outline" class="spt-field">
<mat-label>OD Interest Rate (% p.a.)</mat-label>
<input
type="number"
matInput
formControlName="nominalAnnualInterestRateOverdraft"
placeholder="e.g. 12.5"
min="0"
step="0.01"
required
/>
<mat-hint>Charged on the debit balance</mat-hint>
<mat-error>Enter a rate above 0% and up to 50%</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Minimum OD for Interest (₹)</mat-label>
<input
type="number"
matInput
formControlName="minOverdraftForInterestCalculation"
placeholder="e.g. 100"
min="0"
/>
<mat-hint>No interest while the debit balance is below this</mat-hint>
<mat-error>Cannot be negative</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Interest Posting Frequency</mat-label>
<mat-select formControlName="interestPostingPeriodType" required>
@for (item of interestPostingPeriodTypeData; track item.id) {
<mat-option [value]="item.id">
{{ item.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-hint>Monthly, as RBI requires for advances</mat-hint>
<mat-error>
Interest Posting Frequency {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="interestCalculationDaysInYearType">
@for (item of daysInYearData; track item.id) {
<mat-option [value]="item.id">{{ item.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="spt-footer">
<div class="spt-footer__grid">
@@ -43,25 +99,17 @@
}
</div>
</div>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Frequency (Fix Day)</mat-label>
<input type="number" matInput formControlName="frequencyFixDay" />
<mat-hint>Days</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Last Interest Date</mat-label>
<input matInput [matDatepicker]="lastInterestDatePicker" formControlName="lastInterestDate" />
<mat-datepicker-toggle matSuffix [for]="lastInterestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #lastInterestDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="spt-note">
<span>⚠️</span>
<span>Daily product-based interest accrual enabled — interest calculated EOD on daily closing balance.</span>
</div>
@if (isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>
Rule: daily OD interest = end-of-day debit balance × rate ÷ days in year, applied to the account at the
posting frequency.
</span>
</div>
}
</div>
</div>
</div>
@@ -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;
}
}
@@ -50,6 +50,7 @@
<div class="cp-tab-content">
<mifosx-current-product-terms-step
[CurrentProductsTemplate]="currentProductAndTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step>
</div>
</mat-tab>
@@ -257,16 +257,10 @@
</span>
</div>
}
@if (currentProduct.frequencyFixDay) {
@if (currentProduct.interestCalculationDaysInYearType?.id) {
<div class="vsp-kv">
<span class="vsp-k">Frequency Fix Day</span>
<span class="vsp-v">{{ currentProduct.frequencyFixDay }}</span>
</div>
}
@if (currentProduct.lastInterestDate) {
<div class="vsp-kv">
<span class="vsp-k">Last Interest Date</span>
<span class="vsp-v">{{ currentProduct.lastInterestDate | dateFormat }}</span>
<span class="vsp-k">Days in Year</span>
<span class="vsp-v">{{ currentProduct.interestCalculationDaysInYearType.id }} Days</span>
</div>
}
</div>
@@ -396,37 +390,57 @@
<span class="vsp-k">{{
'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate
}}</span>
<span class="vsp-v">{{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
<span class="vsp-v">₹ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
@if (currentProduct.nominalAnnualInterestRateOverdraft) {
<div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}</span>
<span class="vsp-v">{{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %</span>
<span class="vsp-v">{{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.</span>
</div>
}
@if (currentProduct.overdraftLimit) {
<div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}</span>
<span class="vsp-v">{{ currentProduct.overdraftLimit | formatNumber }}</span>
<span class="vsp-v">₹ {{ currentProduct.overdraftLimit | formatNumber }}</span>
</div>
}
@if (currentProduct.allowOverdraft) {
@if (currentProduct.avgBalanceCalcPeriod) {
@if (currentProduct.odMinLimit) {
<div class="vsp-kv">
<span class="vsp-k">Average Balance Calculation Period</span>
<span class="vsp-v">{{ currentProduct.avgBalanceCalcPeriod }}</span>
<span class="vsp-k">Minimum OD Limit</span>
<span class="vsp-v">₹ {{ currentProduct.odMinLimit | formatNumber }}</span>
</div>
}
@if (currentProduct.odSecurityType) {
<div class="vsp-kv">
<span class="vsp-k">OD Security Type</span>
<span class="vsp-v">{{ currentProduct.odSecurityType | titlecase }}</span>
</div>
}
@if (currentProduct.validForPeriod) {
<div class="vsp-kv">
<span class="vsp-k">Valid for Period</span>
<span class="vsp-k">Sanction Validity</span>
<span class="vsp-v">{{ currentProduct.validForPeriod }}</span>
</div>
}
<div class="vsp-kv">
<span class="vsp-k">Excess Drawing Allowed</span>
<span class="vsp-v">{{ currentProduct.odExcessAllowed | yesNo }}</span>
</div>
@if (currentProduct.odExcessAllowed) {
<div class="vsp-kv">
<span class="vsp-k">Maximum Excess</span>
<span class="vsp-v">{{ currentProduct.odExcessLimitPercent | formatNumber }} % of limit</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Penal Charge on Excess</span>
<span class="vsp-v">{{ currentProduct.odPenalRate | formatNumber }} % p.a.</span>
</div>
}
@if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
<div class="vsp-kv">
<span class="vsp-k">OD Account Closed with Zero Balance in Day Closing</span>
<span class="vsp-k">End OD Once Fully Repaid</span>
<span class="vsp-v">{{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
}
@@ -588,10 +602,34 @@
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="book"></fa-icon></div>
<h3>{{ 'labels.heading.Accounting' | translate }}</h3>
</div>
<mifosx-view-savings-accounting-details
[accountingRule]="currentProduct.accountingRule"
[accountingMappings]="currentProduct.accountingMappings"
></mifosx-view-savings-accounting-details>
<!--
The current product's own mappings only, under current-account names. The shared savings view is not used:
it labels everything as savings and lists savings-only GLs (Saving reference, Interest on savings...), and
while current and savings products share mapping rows by id it would show a savings product's GLs here.
There is no cash (reference) GL: a current account's cash side posts to the branch cash GL.
-->
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Type</span>
<span class="vsp-v">{{ currentProduct.accountingRule?.value || 'Cash' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Cash GL (deposit / withdrawal)</span>
<span class="vsp-v">Branch cash GL — Financial Activity: Cash at Main Vault / Teller</span>
</div>
@for (gl of currentAccountingRows; track gl.label) {
<div class="vsp-kv">
<span class="vsp-k">{{ gl.label }}</span>
<span class="vsp-v">
@if (gl.account) {
({{ gl.account.glCode }}) {{ gl.account.name }}
} @else {
—
}
</span>
</div>
}
</div>
</section>
<!-- OTHER GL MAPPINGS -->
@@ -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;
}
}
@@ -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<any>;
/** 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<void> {
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([]);
}
}
}
@@ -0,0 +1,200 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="oss-root">
@if (loading) {
<div class="oss-section"><div class="oss-sheet-banner">Computing from the ledger…</div></div>
} @else if (errorMsg) {
<div class="oss-balance oss-balance--bad">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ errorMsg }}</span>
</div>
} @else if (data) {
<!-- HEADER -->
<div class="oss-head">
<div class="oss-head-left">
<fa-icon icon="university" class="oss-head-ic"></fa-icon>
<div>
<h2 class="oss-head-t">{{ returnShortName }} — {{ data.returnName }}</h2>
<div class="oss-head-s">
{{ bankName }} &middot; Bank Code <strong>{{ bankCode }}</strong> &middot; Return Code
<strong>{{ data.returnCode }}</strong> &middot; Quarter ending <strong>{{ data.quarter.endDate }}</strong>
</div>
</div>
</div>
<div class="oss-head-right">
<span class="oss-audit-pill">{{ data.quarter.label }}</span>
<input
class="oss-gl-inp"
type="date"
[(ngModel)]="quarterDate"
(change)="onDateChange()"
title="Any date in the quarter to report"
/>
<button class="oss-btn" (click)="resetToToday()">Today</button>
<button class="oss-btn oss-btn-xls" [disabled]="generating" (click)="exportWorkbook()">
<fa-icon icon="file-csv"></fa-icon>
{{ generating ? 'Generating…' : 'Generate Excel' }}
</button>
</div>
</div>
<!--
Headline state, in the same place on every return: the one fact a filer needs before reading
any figure. For OSS 1 that is whether the two sides balance; for OSS 2, the period's result.
-->
<div class="oss-balance" [class.oss-balance--bad]="headlineIsBad">
<fa-icon [icon]="headlineIsBad ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
<span [innerHTML]="headline"></span>
</div>
<!-- TABS -->
<div class="oss-tabs">
@for (t of tabs; track t.id) {
<button class="oss-tab" [class.active]="activeTab === t.id" (click)="showTab(t.id)">
<fa-icon [icon]="t.icon"></fa-icon> {{ t.label }}
</button>
}
</div>
<!--
Caveats ride above the sheets rather than inside one, because a line left blank on any sheet
is a fact about the whole return. A head with nothing mapped is not always a fault: a bank
that holds no investments has no investment account to map, so the list says so rather than
reading as a list of mistakes.
-->
@if (data.unmetRequirements.length) {
<div class="oss-caveat">
<div class="oss-caveat-h">
<fa-icon icon="exclamation-triangle"></fa-icon>
<strong>
{{ data.unmetRequirements.length }}
{{ data.unmetRequirements.length === 1 ? 'line reports' : 'lines report' }} nothing — check before filing
</strong>
</div>
<p class="oss-caveat-lead">
These lines have no GL accounts behind them, so they are left blank rather than reported as nil. If the bank
genuinely has nothing under a head, leaving it unmapped is correct.
</p>
<ul class="oss-caveat-list">
@for (reason of data.unmetRequirements; track reason) {
<li>{{ reason }}</li>
}
</ul>
<a class="oss-caveat-link" [routerLink]="[configRoute]">{{ configLinkLabel }} &rarr;</a>
</div>
}
<!-- GENERAL INFORMATION -->
@if (activeTab === 'general') {
<div class="oss-section">
<div class="oss-sheet-banner">General Information</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl oss-tbl--kv">
<tbody>
@for (item of data.generalInfo; track item.label) {
<tr>
<th>{{ item.label }}</th>
<td [class.filled]="item.configured" [class.oss-unset]="!item.configured">{{ item.value }}</td>
</tr>
}
<tr>
<th>Office scope</th>
<td class="filled">{{ data.officeName }}</td>
</tr>
</tbody>
</table>
</div>
<p class="oss-note">{{ data.basis.note }}</p>
</div>
}
<!-- FIGURES -->
@if (activeTab === 'figures') {
<div class="oss-section">
@for (section of sections(); track section.title) {
<div class="oss-sheet-banner">{{ section.title }}</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>{{ section.title }}</th>
<th class="ta-r">{{ section.unit }}</th>
</tr>
</thead>
<tbody>
@for (line of section.lines; track line.label) {
<tr [class.total-row]="line.subtotal">
<td [class]="line.subtotal ? 'lvl-0' : 'lvl-1'">{{ line.label }}</td>
<td class="ta-r">
@if (!line.configured) {
<span class="oss-unset" title="No GL accounts mapped to this line">Not configured</span>
} @else if (line.count) {
{{ count(line.amount) }}
} @else {
{{ amount(line.amount) }}
}
</td>
</tr>
<!-- Account-level breakdown, so every total traces back to the accounts behind it. -->
@for (account of line.accounts; track account.glAccountId) {
<tr class="oss-subline" [class.oss-subline--excluded]="account.excluded">
<td class="lvl-2">
<span class="oss-gl-code">{{ account.glCode }}</span>
{{ account.name }}
@if (account.excluded) {
<span class="oss-unset">excluded — {{ account.excluded }}</span>
}
</td>
<td class="ta-r">{{ amount(account.balance) }}</td>
</tr>
}
}
@for (total of totals(); track total.label) {
<tr class="total-row">
<td class="lvl-0">{{ total.label }}</td>
<td class="ta-r">{{ amount(total.value) }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- SIGNATORY -->
@if (activeTab === 'signatory') {
<div class="oss-section">
<div class="oss-sheet-banner">Signatory Information</div>
@for (signatory of data.signatories; track signatory.heading) {
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">{{ signatory.heading }}</div>
<table class="oss-tbl oss-tbl--kv">
<tbody>
@for (field of signatoryFields(signatory); track field.caption) {
<tr>
<th>{{ field.caption }}</th>
<td [class.filled]="field.value" [class.oss-unset]="!field.value">
{{ field.value || 'Not set in Institution Profile' }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
@if (toastVisible) {
<div class="oss-toast show">{{ toastMsg }}</div>
}
</div>
@@ -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 <strong>&#8377;${liabilities}</strong> &middot; ` +
`Total Assets <strong>&#8377;${assets}</strong> &middot; the two sides agree`
);
}
return (
`Total Liabilities <strong>&#8377;${liabilities}</strong> &middot; ` +
`Total Assets <strong>&#8377;${assets}</strong> &middot; ` +
`OUT OF BALANCE by <strong>&#8377;${this.amount(Math.abs(this.data.imbalance ?? 0))}</strong> — ` +
'resolve before filing'
);
}
protected fetch(quarterEndDate: string): Observable<any> {
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;
}
}
@@ -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 <strong>&#8377;${this.amount(this.data.totalIncome)}</strong> &middot; ` +
`Total Expenditure <strong>&#8377;${this.amount(this.data.totalExpenditure)}</strong> &middot; ` +
`${result >= 0 ? 'Operating Profit' : 'Operating Loss'} ` +
`<strong>&#8377;${this.amount(Math.abs(result))}</strong> for the quarter`
);
}
protected fetch(quarterEndDate: string): Observable<any> {
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
}
];
}
}
+89
View File
@@ -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;
}
@@ -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)' }
}
])
];
@@ -60,4 +60,75 @@ export class RbiCircularsService {
getShareCapitalReturnYears(): Observable<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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;
}
}
@@ -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<any> {
return this.rbiService.getForm1(reportingDate);
}
ngOnInit(): void {
this.load();
}
}
@@ -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<any> {
return this.rbiService.getForm9(reportingDate);
}
ngOnInit(): void {
this.load();
}
}
@@ -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<any>;
/** 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<void> {
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);
}
}
@@ -0,0 +1,320 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="rr-container">
@if (loading) {
<div class="rr-loading">Computing from the ledger…</div>
} @else if (errorMsg) {
<div class="rr-alert rr-alert--error">{{ errorMsg }}</div>
} @else if (data) {
<!-- ═══ Header ═══ -->
<div class="rr-header">
<div class="rr-header__text">
<h2 class="rr-header__title">{{ data.formName }}</h2>
<span class="rr-header__freq">{{ data.frequency }}</span>
@if (data.fortnightLabel) {
<span class="rr-header__period">
{{ data.fortnightLabel }} · {{ data.fortnightStart }} to {{ data.fortnightEnd }}
</span>
} @else {
<span class="rr-header__period">Month ended {{ data.reportingDate }}</span>
}
</div>
<div class="rr-header__actions">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [(ngModel)]="reportingDate" (change)="onDateChange()" />
</mat-form-field>
<button mat-stroked-button (click)="resetToToday()">Today</button>
<button mat-raised-button color="primary" [disabled]="generating" (click)="exportWorkbook()">
<i class="fas fa-file-excel"></i>
{{ generating ? 'Generating…' : 'Export' }}
</button>
</div>
</div>
<!--
Caveats show whenever there are any — not only when the return cannot compute. A return
computed on the placeholder rate is arithmetically right and still must not be filed as-is,
so hiding that behind the not-computable branch would bury the one thing worth saying.
-->
@if (data.unmetRequirements.length) {
<div class="rr-alert rr-alert--warn">
<strong>
{{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }}
</strong>
<ul class="rr-unmet">
@for (reason of data.unmetRequirements; track reason) {
<li>{{ reason }}</li>
}
</ul>
<a class="rr-unmet__link" [routerLink]="['/organization/rbi-reserve-config']">
Open RBI Reserve Configuration →
</a>
</div>
}
<!-- ═══ Basis ═══ -->
<div class="rr-basis">
<div class="rr-basis__row">
<div class="rr-basis__item">
<span class="rr-basis__label">DTL base as at</span>
<span class="rr-basis__value">{{ data.basis.dtlAsOnDate }}</span>
</div>
<div class="rr-basis__arrow"><i class="fas fa-arrow-right"></i></div>
<div class="rr-basis__item">
<span class="rr-basis__label">Balances maintained as at</span>
<span class="rr-basis__value">{{ data.basis.holdingsAsOnDate }}</span>
</div>
@if (data.basis.rawLagDate) {
<div class="rr-basis__item rr-basis__item--muted">
<span class="rr-basis__label">{{ data.basis.lagDays }}-day lag falls on</span>
<span class="rr-basis__value">{{ data.basis.rawLagDate }}</span>
</div>
}
</div>
<p class="rr-basis__note">{{ data.basis.note }}</p>
</div>
<!-- ═══ Summary ═══ -->
@if (data.computable) {
<div class="rr-summary">
<!-- NDTL, not DTL: the rate is charged on the net figure, so the base shown must be the one
the required amount was actually computed from. DTL and what was netted off it are
given underneath, so the subtraction is visible rather than implied. -->
<div class="rr-stat">
<span class="rr-stat__label">NDTL base</span>
<span class="rr-stat__value">{{ amount(data.ndtlAmount) }}</span>
<span class="rr-stat__sub">
DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at
{{ data.basis.dtlAsOnDate }}
</span>
</div>
<div class="rr-stat" [class.rr-stat--provisional]="data.rateIsSeedDefault">
<span class="rr-stat__label">{{ data.rateType }} rate</span>
<span class="rr-stat__value">{{ data.ratePercentage }}%</span>
@if (data.rateIsSeedDefault) {
<span class="rr-stat__sub rr-stat__sub--warn">Placeholder — not confirmed</span>
} @else {
<span class="rr-stat__sub">in force on {{ data.reportingDate }}</span>
}
</div>
<div class="rr-stat">
<span class="rr-stat__label">Required</span>
<span class="rr-stat__value">{{ amount(data.requiredAmount) }}</span>
<span class="rr-stat__sub">{{ data.ratePercentage }}% of NDTL</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Maintained</span>
<span class="rr-stat__value">{{ amount(data.maintainedAmount) }}</span>
<span class="rr-stat__sub">{{ percent(data.maintainedPercentage) }} of NDTL</span>
</div>
<div class="rr-stat" [class.rr-stat--bad]="isShortfall" [class.rr-stat--good]="!isShortfall">
<span class="rr-stat__label">{{ isShortfall ? 'Shortfall' : 'Surplus' }}</span>
<span class="rr-stat__value">{{ amount(shortfallAmount) }}</span>
<span class="rr-stat__sub">{{ isShortfall ? 'Below requirement' : 'Above requirement' }}</span>
</div>
</div>
}
<!-- ═══ Lines ═══ -->
<div class="rr-card">
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
<!-- Tracked by position, not by configKey: derived rows (the demand/time split, NDTL)
have no config key of their own, so keying on it would collide them into one. -->
@for (line of data.lines; track $index) {
<tr
class="rr-line"
[class.rr-line--unconfigured]="!line.configured"
[class.rr-line--derived]="line.derived"
>
<td>
<span class="rr-line__label">{{ line.label }}</span>
@if (!line.configured) {
<span class="rr-badge rr-badge--unmapped">Not configured</span>
}
</td>
<td class="rr-line__date">{{ line.asOnDate }}</td>
<td class="rr-table__num">
@if (line.configured) {
{{ amount(line.amount) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
</tr>
<!-- Account-level breakdown, so every total traces back to the accounts behind it. -->
@for (account of line.accounts; track account.glAccountId) {
<tr class="rr-subline" [class.rr-subline--excluded]="account.excluded">
<td>
<span class="rr-subline__code">{{ account.glCode }}</span>
{{ account.name }}
<!-- An account still pinned to the role but out of the total: named here so the
line's figure can be read as short by it, rather than looking complete. -->
@if (account.excluded) {
<span class="rr-subline__excluded">excluded — {{ account.excluded }}</span>
}
<!-- Where only part of an account is taken, the figure below is not the account's
balance and must not be read as one: state the whole balance and the share of
it this head takes, so the two can be reconciled without opening the ledger. -->
@if (isPartial(account)) {
<span class="rr-subline__portion">
{{ account.weightPercentage }}% of {{ amount(account.accountBalance) }}
</span>
}
<!-- Which head the portion falls under, on a liability line that carries both. -->
@if (account.liabilityClass) {
<span class="rr-subline__class">{{ account.liabilityClass | titlecase }}</span>
}
</td>
<td></td>
<td class="rr-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
@if (data.computable) {
<tr class="rr-total">
<td>{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL)</td>
<td></td>
<td class="rr-table__num">{{ amount(data.requiredAmount) }}</td>
</tr>
<tr class="rr-total">
<td>{{ data.rateType }} maintained</td>
<td></td>
<td class="rr-table__num">{{ amount(data.maintainedAmount) }}</td>
</tr>
<tr class="rr-total" [class.rr-total--bad]="isShortfall">
<td>{{ isShortfall ? 'Shortfall' : 'Surplus' }}</td>
<td></td>
<td class="rr-table__num">{{ amount(shortfallAmount) }}</td>
</tr>
}
</tbody>
</table>
</div>
<!--
═══ Fortnight maintenance ═══
CRR is not met by the closing day's balance: the requirement must be held on average across
the fortnight AND a daily minimum on every day of it. A bank can pass the closing position and
still fail, so both tests are stated.
-->
@if (data.maintenance; as m) {
<div class="rr-card rr-card--section">
<h3 class="rr-section__title">
Maintenance over the fortnight
@if (!m.fortnightComplete) {
<span class="rr-badge rr-badge--partial">In progress — {{ m.dayCount }} days so far</span>
}
</h3>
<p class="rr-section__hint">
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.
</p>
<div class="rr-summary">
<div class="rr-stat" [class.rr-stat--good]="m.averageMet" [class.rr-stat--bad]="!m.averageMet">
<span class="rr-stat__label">Average maintained</span>
<span class="rr-stat__value">{{ amount(m.average) }}</span>
<span class="rr-stat__sub">
{{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }}
</span>
</div>
<div class="rr-stat" [class.rr-stat--good]="m.dailyMinimumMet" [class.rr-stat--bad]="!m.dailyMinimumMet">
<span class="rr-stat__label">Lowest day</span>
<span class="rr-stat__value">{{ amount(m.lowest) }}</span>
<span class="rr-stat__sub">
on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }}
</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Days counted</span>
<span class="rr-stat__value">{{ m.dayCount }}</span>
<span class="rr-stat__sub">{{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }}</span>
</div>
</div>
<table class="rr-table">
<thead>
<tr>
<th>Date</th>
<th class="rr-table__num">Maintained (INR)</th>
<th class="rr-table__num">Against floor</th>
</tr>
</thead>
<tbody>
@for (day of m.days; track day.date) {
<tr [class.rr-day--short]="day.amount < m.dailyMinimumAmount">
<td>{{ day.date }}</td>
<td class="rr-table__num">{{ amount(day.amount) }}</td>
<td class="rr-table__num">
@if (day.amount < m.dailyMinimumAmount) {
<span class="rr-badge rr-badge--unmapped">Below floor</span>
} @else {
<span class="rr-ok">OK</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ═══ Memorandum and Annexures ═══ -->
@for (section of namedSections; track section.title) {
@if (section.rows.length) {
<div class="rr-card rr-card--section">
<h3 class="rr-section__title">{{ section.title }}</h3>
<p class="rr-section__hint">{{ section.hint }}</p>
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
@for (row of section.rows; track row.configKey) {
<tr [class.rr-line--unconfigured]="!row.configured">
<td>
{{ row.label }}
@if (!row.configured) {
<span class="rr-badge rr-badge--unmapped">Not configured</span>
}
</td>
<td class="rr-table__num">
@if (row.configured) {
{{ amount(row.amount) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
@if (toastVisible) {
<div class="rr-toast">{{ toastMsg }}</div>
}
</div>
@@ -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;
}
}
@@ -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)
},
@@ -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,
+2 -1
View File
@@ -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;
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -24,6 +24,13 @@
</button>
</div>
@if (loadError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ loadError }}</div>
</div>
}
<!-- Summary -->
<div class="tdsr__kpis">
<div class="tdsr__kpi">
@@ -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.
@@ -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;
/**
+6 -6
View File
@@ -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';
@@ -205,7 +205,7 @@ export function tdsChallans(deductedByQuarter: Record<string, Record<number, num
}
const out: Record<string, TdsChallan[]> = {};
for (const fy of TDS_FYS) {
for (const fy of Object.keys(deductedByQuarter)) {
const y = Number(fy.slice(0, 4));
const months: Record<number, string> = {
1: `${y}-06`,
@@ -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
+3 -1
View File
@@ -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;
}
+6 -8
View File
@@ -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<string, any> = {};
// 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;
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260923',
'hash': '3f59cc6b'
'version': '260924',
'hash': 'a6e62201'
},
'allow_switching_backend_instance': true
};