feat: update RBI reserve return form 1 & 9 UI and FD accounting mapping

This commit is contained in:
2026-09-28 13:36:29 +05:30
parent 6cbb31b5c6
commit eb278978bd
17 changed files with 1116 additions and 366 deletions
@@ -334,17 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) {
// Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0
// months, which the backend rejects -- send it as tenureInDays instead in that case.
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} else if (enteredDays >= 30) {
tenureInMonths = Math.floor(enteredDays / 30);
} else {
// Sent as days, whatever the count. Converting to whole months silently dropped the
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
// screen still said "200 Days" above the figure. The backend takes days directly and
// works the calendar out itself.
tenureInDays = enteredDays;
}
@@ -423,10 +424,17 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
} else {
const n = 12 / compMonths;
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const totalCompoundings = Math.floor(effectiveMonths / compMonths);
const rPerCompounding = rate / (n * 100);
result.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
// Whole compounding periods, then simple interest on the days left over. Flooring the
// periods and stopping there dropped the remainder entirely: a 200-day deposit
// compounding monthly quoted only six months of interest and lost the other twenty
// days, which is the depositor's money.
const wholePeriods = Math.floor(effectiveMonths / compMonths);
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
const leftoverInterest =
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
}
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
@@ -27,101 +27,104 @@
} @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-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>
}
<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-badge rrc-badge--seed">Placeholder</span>
<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>
}
</td>
<td>
<button mat-button class="rrc-remove" (click)="deleteRate(rate)" title="Remove this rate">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
} @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>
}
</tbody>
</table>
</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 ═══ -->
@@ -324,6 +327,17 @@
<input matInput [(ngModel)]="newRole.description" placeholder="What this head covers" />
</mat-form-field>
<!--
The section prefixes are a naming convention rather than a field, so they have to be
stated somewhere: an administrator who wants a head on the Memorandum has no way to
discover that naming it so is what puts it there.
-->
<p class="rrc-card__hint rrc-add-role__note">
Naming a head <strong>Memorandum - …</strong>, <strong>Annexure A - …</strong> or
<strong>Annexure B - …</strong> prints it under that section of the return instead of in the main
statement.
</p>
<button mat-raised-button color="primary" [disabled]="addingRole" (click)="addRole()">
{{ addingRole ? 'Adding…' : 'Add head' }}
</button>
@@ -297,7 +297,7 @@ $red: #c5221f;
&--active {
border-color: $primary;
background: rgba(19, 83, 160, 0.06);
background: rgb(19 83 160 / 6%);
color: $primary;
}
@@ -306,13 +306,13 @@ $red: #c5221f;
font-weight: 500;
padding: 1px 7px;
border-radius: 10px;
background: rgba(176, 96, 0, 0.12);
background: rgb(176 96 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);
background: rgb(19 115 51 / 12%);
color: $green;
}
}
@@ -379,6 +379,12 @@ $red: #c5221f;
}
}
// Spans the add-head form's columns rather than sitting in one of them.
.rrc-add-role__note {
flex-basis: 100%;
margin: 0 !important;
}
.rrc-remove {
min-width: 0 !important;
padding: 0 8px !important;
@@ -397,7 +403,7 @@ $red: #c5221f;
padding: 3px 10px;
}
@media (max-width: 720px) {
@media (width <= 720px) {
.rrc-rate-form__type,
.rrc-rate-form__pct,
.rrc-rate-form__date,
@@ -419,7 +425,7 @@ $red: #c5221f;
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.55);
color: rgb(0 0 0 / 55%);
font-weight: 600;
padding: 0 0.5rem 0.25rem 0;
}
@@ -447,7 +453,7 @@ $red: #c5221f;
/* 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);
background: rgb(214 106 0 / 6%);
}
}
@@ -460,7 +466,7 @@ $red: #c5221f;
.rrc-add {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px dashed rgba(0, 0, 0, 0.12);
border-top: 1px dashed rgb(0 0 0 / 12%);
&__form {
display: flex;
@@ -91,12 +91,19 @@ export class RbiReserveConfigComponent implements OnInit {
{ value: 'TIME', label: 'Time' }
];
/** How a head an administrator adds is consumed by the return. */
/**
* How a head an administrator adds is consumed by the return.
*
* MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
* met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
* both would let a head meant for Form 1 quietly satisfy Form 9.
*/
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' }
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
{ value: 'MEMO', label: 'Memo — shown on the return but totals nothing' }
];
/** New-head form. */
@@ -168,10 +175,12 @@ export class RbiReserveConfigComponent implements OnInit {
}
byGroup.get(group)!.roles.push(role);
}
this.roleGroups = [...byGroup.entries()].map(([
name,
entry
]) => ({ name, label: entry.label, roles: entry.roles }));
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.
@@ -237,8 +246,7 @@ export class RbiReserveConfigComponent implements OnInit {
// a bank look compliant when it is not.
this.selection[role.configKey] = [
...(this.selection[role.configKey] || []),
{ glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }
];
{ glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }];
}
removeMapping(role: any, index: number): void {
@@ -252,9 +260,7 @@ export class RbiReserveConfigComponent implements OnInit {
*/
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 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 ?? ''}`));
}
@@ -308,6 +308,11 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
setRequired('incomeFromPenaltyAccountId', cashOrAccrual);
setRequired('feesReceivableAccountId', accrualOnly);
setRequired('penaltiesReceivableAccountId', accrualOnly);
// Required only on accrual, where there is no posting without it. On cash it stays optional but
// is offered, because interest posting routes through it when the product names one (see
// AccountingProcessorHelper.createInterestPostingJournalEntriesForSavings): the payable carries
// interest declared but not yet credited, and a product that names none collapses straight to
// Savings Control instead.
setRequired('interestPayableAccountId', accrualOnly);
setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled());
@@ -326,8 +331,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
label: 'Fixed Deposit Reference Account',
inputLabel: 'Fixed deposit reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
@@ -360,8 +365,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
label: 'Fixed Deposit Control Account',
inputLabel: 'Fixed deposit control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
@@ -375,12 +380,16 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
required: true
},
{
// Offered on cash accounting too, not only accrual: interest posting routes through
// this account whichever basis the product uses, so that interest declared but not yet
// credited to the member reads as the bank's own liability rather than appearing only
// when it is paid. Optional on cash - a product naming none posts straight to Savings
// Control as before.
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId',
options: this.liabilityAccountOptions,
accrualOnly: true
options: this.liabilityAccountOptions
},
{
key: 'escheatLiabilityId',
@@ -402,8 +411,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
label: 'Interest On Fixed Deposits',
inputLabel: 'Interest on fixed deposits',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
@@ -481,6 +481,7 @@
<mifosx-view-savings-accounting-details
[accountingRule]="accountingRule"
[accountingMappings]="accountingMappings"
productLabel="Fixed deposit"
></mifosx-view-savings-accounting-details>
}
@@ -607,6 +607,7 @@
<mifosx-view-savings-accounting-details
[accountingRule]="fixedDepositProductData.accountingRule"
[accountingMappings]="fixedDepositProductData.accountingMappings"
productLabel="Fixed deposit"
></mifosx-view-savings-accounting-details>
}
</section>
@@ -121,6 +121,47 @@ export class RbiCircularsService {
return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) });
}
// ── Saved returns ──
/**
* Returns saved earlier, newest first. A saved return is served from the snapshot taken when it
* was saved, never recomputed — a backdated entry or a changed mapping would otherwise produce a
* different statement from the one that was reviewed.
*/
getSavedReturns(formCode?: string): Observable<any> {
let params = new HttpParams();
if (formCode) {
params = params.set('formCode', formCode);
}
return this.http.get('/rbiformafiling', { params });
}
/** One saved return, with the stored snapshot, so the screen renders what was saved. */
getSavedReturn(filingId: number): Observable<any> {
return this.http.get(`/rbiformafiling/${filingId}`);
}
/** Keeps the return currently on screen. Re-saving the same form and date replaces it. */
saveReturn(formCode: string, reportingDate: string, report: any, remarks?: string): Observable<any> {
return this.http.post('/rbiformafiling', {
formCode,
reportingDate,
report,
remarks,
locale: 'en',
dateFormat: 'yyyy-MM-dd'
});
}
/** Moves a saved return along its filing lifecycle; the snapshot itself is untouched. */
updateSavedReturnStatus(filingId: number, status: string, remarks?: string): Observable<any> {
return this.http.post(`/rbiformafiling/${filingId}/status`, { status, remarks });
}
deleteSavedReturn(filingId: number): Observable<any> {
return this.http.delete(`/rbiformafiling/${filingId}`);
}
private ossParams(quarterEndDate?: string, officeId?: number): HttpParams {
let params = new HttpParams();
if (quarterEndDate) {
@@ -30,6 +30,7 @@ import { ReserveReturnBase } from './reserve-return.base';
})
export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form1_CRR';
protected readonly formCode = 'FORM_1';
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm1(reportingDate);
@@ -37,5 +38,6 @@ export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
ngOnInit(): void {
this.load();
this.loadSaved();
}
}
@@ -30,6 +30,7 @@ import { ReserveReturnBase } from './reserve-return.base';
})
export class Form9SlrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form9_SLR';
protected readonly formCode = 'FORM_9';
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm9(reportingDate);
@@ -37,5 +38,6 @@ export class Form9SlrComponent extends ReserveReturnBase implements OnInit {
ngOnInit(): void {
this.load();
this.loadSaved();
}
}
@@ -64,7 +64,12 @@ export interface SectionRow {
* 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 }[];
/** Each day of the month, with the requirement it was measured against and the gap. */
days: { date: string; required: number; amount: number; surplus: number }[];
/** The month the table covers. */
periodStart?: string;
periodEnd?: string;
periodLabel?: string;
dayCount: number;
average: number;
lowest: number;
@@ -146,6 +151,9 @@ export abstract class ReserveReturnBase {
/** Filename stem for the Excel export. */
protected abstract readonly exportStem: string;
/** Which return this screen is, so saved returns list only this one's. */
protected abstract readonly formCode: string;
data: ReserveReturn | null = null;
loading = true;
errorMsg = '';
@@ -158,6 +166,33 @@ export abstract class ReserveReturnBase {
toastVisible = false;
private toastTimer: any;
/** Returns saved earlier, listed under the live one. */
savedReturns: any[] = [];
savedLoading = false;
saving = false;
savedRemarks = '';
/**
* Which half of the screen is showing: the return itself, or the ones kept on record.
*
* Two tabs rather than one long page: the live return and the saved list are read at different
* times and for different reasons, and stacking them meant scrolling past the whole statement -
* including a month of daily rows - to reach the list at all.
*/
activeTab: 'report' | 'saved' = 'report';
showTab(tab: 'report' | 'saved'): void {
this.activeTab = tab;
}
/** The saved return currently open below the list, or null when none is. */
viewingSaved: any = null;
/**
* The opened snapshot's own statement, rendered in its own section. Held separately from `data` so
* the live return stays on screen alongside it and the two can be compared directly.
*/
savedData: ReserveReturn | null = null;
load(): void {
this.loading = true;
this.errorMsg = '';
@@ -224,6 +259,167 @@ export abstract class ReserveReturnBase {
return Math.abs(this.data?.surplusOrShortfall ?? 0);
}
// ── Saved returns ──
loadSaved(): void {
this.savedLoading = true;
this.rbiService
.getSavedReturns(this.formCode)
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.savedLoading = false;
this.savedReturns = rows || [];
});
}
/** Keeps the return on screen, so it can be read again later exactly as it stands now. */
saveCurrent(): void {
if (!this.data) {
return;
}
this.saving = true;
this.rbiService
.saveReturn(this.data.formCode, this.data.reportingDate, this.data, this.savedRemarks || undefined)
.pipe(
catchError((err: any) => {
this.saving = false;
this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this return.');
return of(null);
})
)
.subscribe((rows: any[] | null) => {
this.saving = false;
if (!rows) {
return;
}
this.savedReturns = rows;
this.savedRemarks = '';
this.toast('Return saved.');
});
}
/**
* Opens a saved return in its own section below the list, leaving the live return on screen.
*
* Deliberately not in place of the live one: the reason to open a saved return is almost always to
* compare it against where the bank stands now, and swapping the live figures out makes that
* comparison impossible without clicking back and forth from memory.
*/
viewSaved(row: any): void {
// Clicking the open one closes it, so the button toggles rather than re-fetching.
if (this.viewingSaved?.id === row.id) {
this.closeSaved();
return;
}
this.rbiService
.getSavedReturn(row.id)
.pipe(
catchError(() => {
this.toast('Could not open this saved return.');
return of(null);
})
)
.subscribe((saved: any) => {
if (!saved?.report) {
this.toast('This saved return has no stored statement.');
return;
}
this.viewingSaved = saved;
this.savedData = saved.report as ReserveReturn;
// Opens on the Saved tab, where the list it was chosen from is - never jumping the user to
// the live return, which is a different statement.
this.activeTab = 'saved';
setTimeout(() => document.getElementById('rr-saved-view')?.scrollIntoView({ behavior: 'smooth' }), 0);
});
}
/** Closes the opened snapshot. The live return was never hidden, so nothing is restored. */
closeSaved(): void {
this.viewingSaved = null;
this.savedData = null;
}
/** Kept for callers that still name the old action. */
backToLive(): void {
this.closeSaved();
}
/** Magnitude of the opened snapshot's gap, so the sign lives in its label rather than the number. */
get savedSurplus(): number {
return Math.abs(this.savedData?.surplusOrShortfall ?? 0);
}
updateSavedStatus(row: any, status: string): void {
this.rbiService
.updateSavedReturnStatus(row.id, status)
.pipe(
catchError((err: any) => {
this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not update the status.');
return of(null);
})
)
.subscribe((rows: any[] | null) => {
if (!rows) {
return;
}
this.savedReturns = rows;
if (this.viewingSaved?.id === row.id) {
this.viewingSaved = { ...this.viewingSaved, status };
}
this.toast(`Marked ${this.statusLabel(status)}.`);
});
}
deleteSaved(row: any): void {
this.rbiService
.deleteSavedReturn(row.id)
.pipe(
catchError(() => {
this.toast('Could not remove this saved return.');
return of(null);
})
)
.subscribe((rows: any[] | null) => {
if (!rows) {
return;
}
this.savedReturns = rows;
if (this.viewingSaved?.id === row.id) {
this.backToLive();
}
this.toast('Saved return removed.');
});
}
/** The stages a return moves through, in order, for the status menu. */
readonly filingStatuses = [
'DRAFT',
'GENERATED',
'CHECKED',
'APPROVED',
'PROVISIONAL_SUBMITTED',
'FINAL_SUBMITTED',
'REVISED'
];
statusLabel(status: string): string {
const labels: Record<string, string> = {
DRAFT: 'Draft',
GENERATED: 'Generated',
CHECKED: 'Checked',
APPROVED: 'Approved',
PROVISIONAL_SUBMITTED: 'Provisional submitted',
FINAL_SUBMITTED: 'Final submitted',
REVISED: 'Revised'
};
return labels[status] ?? status;
}
/** Hands the rendered return to the browser's print dialog, which also prints to PDF. */
printReturn(): void {
window.print();
}
/**
* 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.
@@ -268,17 +464,25 @@ export abstract class ReserveReturnBase {
try {
const wb = newWorkbook();
const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' '));
// Five columns because the daily table is the widest block on the sheet; the statement above
// it uses the first three and leaves the rest empty.
sheet.columns = [
{ width: 52 },
{ width: 22 },
{ width: 20 }];
{ width: 20 },
{ width: 22 },
{ width: 16 }];
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,
'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);
@@ -388,16 +592,26 @@ export abstract class ReserveReturnBase {
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,
`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)'}`);
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)',
'Required (INR)',
'Actual (INR)',
'Surplus / (Shortfall)',
'Against floor'
]);
dayHead.font = { bold: true };
@@ -406,13 +620,21 @@ export abstract class ReserveReturnBase {
for (const day of m.days) {
const row = sheet.addRow([
day.date,
day.required,
day.amount,
day.surplus,
day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK'
]);
row.getCell(2).numFmt = '#,##0.00';
row.getCell(2).alignment = { horizontal: 'right' };
for (const col of [
2,
3,
4
]) {
row.getCell(col).numFmt = '#,##0.00';
row.getCell(col).alignment = { horizontal: 'right' };
}
}
boxAllCells(sheet, dayStart, sheet.rowCount, 3);
boxAllCells(sheet, dayStart, sheet.rowCount, 5);
}
// The Memorandum and Annexures, each under its own heading.
@@ -32,6 +32,14 @@
<input matInput type="date" [(ngModel)]="reportingDate" (change)="onDateChange()" />
</mat-form-field>
<button mat-stroked-button (click)="resetToToday()">Today</button>
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
</button>
<button mat-stroked-button [disabled]="saving || !data.computable" (click)="saveCurrent()">
<i class="fas fa-bookmark"></i>
{{ saving ? 'Saving…' : 'Save' }}
</button>
<button mat-raised-button color="primary" [disabled]="generating" (click)="exportWorkbook()">
<i class="fas fa-file-excel"></i>
{{ generating ? 'Generating…' : 'Export' }}
@@ -40,269 +48,255 @@
</div>
<!--
═══ Tabs ═══
The return and the saved list are read at different times and for different reasons, so they
are two halves of the screen rather than one page where reaching the list means scrolling past
a whole statement and a month of daily rows.
-->
<div class="rr-tabs rr-noprint">
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'report'" (click)="showTab('report')">
<i class="fas fa-file-invoice"></i> Report
</button>
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'saved'" (click)="showTab('saved')">
<i class="fas fa-bookmark"></i> Saved returns
@if (savedReturns.length) {
<span class="rr-tab__count">{{ savedReturns.length }}</span>
}
</button>
</div>
@if (activeTab === 'report') {
<!--
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>
}
@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>
<!-- ═══ 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>
<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
<!-- ═══ 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>
<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">
{{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }}
DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at
{{ data.basis.dtlAsOnDate }}
</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 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">Days counted</span>
<span class="rr-stat__value">{{ m.dayCount }}</span>
<span class="rr-stat__sub">{{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }}</span>
<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>Date</th>
<th class="rr-table__num">Maintained (INR)</th>
<th class="rr-table__num">Against floor</th>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</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>
<!-- 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>
}
<!-- ═══ Memorandum and Annexures ═══ -->
@for (section of namedSections; track section.title) {
@if (section.rows.length) {
<!--
═══ 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">{{ section.title }}</h3>
<p class="rr-section__hint">{{ section.hint }}</p>
<h3 class="rr-section__title">
Daily maintenance — {{ m.periodLabel || 'this month' }}
@if (!m.fortnightComplete) {
<span class="rr-badge rr-badge--partial">In progress — {{ m.dayCount }} days so far</span>
}
</h3>
<p class="rr-section__hint">
Every day of the month, against the requirement in force. CRR is reckoned over the period rather than 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>Particulars</th>
<th class="rr-table__num">Amount (INR)</th>
<th>Date</th>
<th class="rr-table__num">Required (INR)</th>
<th class="rr-table__num">Actual (INR)</th>
<th class="rr-table__num">Surplus / (Shortfall)</th>
<th class="rr-table__num">Against floor</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>
@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.required) }}</td>
<td class="rr-table__num">{{ amount(day.amount) }}</td>
<td class="rr-table__num" [class.rr-neg]="day.surplus < 0">{{ amount(day.surplus) }}</td>
<td class="rr-table__num">
@if (row.configured) {
{{ amount(row.amount) }}
@if (day.amount < m.dailyMinimumAmount) {
<span class="rr-badge rr-badge--unmapped">Below floor</span>
} @else {
<span class="rr-dash">—</span>
<span class="rr-ok">OK</span>
}
</td>
</tr>
@@ -311,6 +305,250 @@
</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>
}
}
}
}
<!--
═══ Saved returns ═══
Outside the `@if (data)` block so the list stays reachable even when the live return cannot be
computed — a saved return from a month when it could is exactly what is wanted then, and a tab
that vanishes when the live figures fail would hide exactly the returns that still work.
-->
@if (activeTab === 'saved') {
<div class="rr-card rr-card--section rr-noprint">
<h3 class="rr-section__title">
Saved returns
@if (savedReturns.length) {
<span class="rr-badge rr-badge--mapped">{{ savedReturns.length }}</span>
}
</h3>
<p class="rr-section__hint">
Returns kept for the record. Each opens exactly as it was saved — the stored statement, not a re-run of today's
ledger, so a figure already reported cannot change underneath it.
</p>
@if (data?.computable) {
<div class="rr-save-row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-save-row__note">
<mat-label>Note (optional)</mat-label>
<input matInput [(ngModel)]="savedRemarks" placeholder="e.g. provisional filing for this fortnight" />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="saving" (click)="saveCurrent()">
{{ saving ? 'Saving…' : 'Save this return' }}
</button>
</div>
}
@if (savedLoading) {
<div class="rr-loading">Loading saved returns…</div>
} @else if (!savedReturns.length) {
<div class="rr-empty">Nothing saved yet. Generate a return and press Save to keep it.</div>
} @else {
<table class="rr-table">
<thead>
<tr>
<th>Reporting date</th>
<th>Period</th>
<th class="rr-table__num">NDTL</th>
<th class="rr-table__num">Required</th>
<th class="rr-table__num">Maintained</th>
<th>Status</th>
<th>Saved</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of savedReturns; track row.id) {
<tr [class.rr-saved--open]="viewingSaved?.id === row.id">
<td>{{ row.reportingDate }}</td>
<td class="rr-saved__period">{{ row.periodStart }} → {{ row.periodEnd }}</td>
<td class="rr-table__num">{{ amount(row.ndtlAmount) }}</td>
<td class="rr-table__num">{{ amount(row.requiredAmount) }}</td>
<td class="rr-table__num">{{ amount(row.maintainedAmount) }}</td>
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-saved__status">
<mat-select [value]="row.status" (selectionChange)="updateSavedStatus(row, $event.value)">
@for (s of filingStatuses; track s) {
<mat-option [value]="s">{{ statusLabel(s) }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td class="rr-saved__meta">{{ row.createdOn }}<br />{{ row.createdBy }}</td>
<td class="rr-saved__actions">
<button mat-button class="rr-saved__view" (click)="viewSaved(row)">
{{ viewingSaved?.id === row.id ? 'Close' : 'View' }}
</button>
<button mat-button class="rr-saved__del" (click)="deleteSaved(row)" title="Remove">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
<!--
═══ The opened snapshot ═══
Its own section below the list rather than in place of the live return: the reason to open a
saved return is almost always to compare it against where the bank stands now, which swapping
the live figures out would make impossible.
-->
@if (viewingSaved && savedData; as _) {
<div class="rr-card rr-card--section rr-card--saved" id="rr-saved-view">
<div class="rr-saved-head">
<div>
<h3 class="rr-section__title">
Saved return — {{ savedData.reportingDate }}
<span class="rr-badge rr-badge--future">{{ statusLabel(viewingSaved.status) }}</span>
</h3>
<p class="rr-section__hint">
Saved {{ viewingSaved.createdOn }} by {{ viewingSaved.createdBy || 'unknown' }}. These are the figures as
they stood when it was saved — not today's ledger, which may have moved since.
@if (viewingSaved.remarks) {
<em class="rr-saved__remarks">“{{ viewingSaved.remarks }}”</em>
}
</p>
</div>
<button mat-stroked-button (click)="closeSaved()">Close</button>
</div>
<div class="rr-summary">
<div class="rr-stat">
<span class="rr-stat__label">NDTL base</span>
<span class="rr-stat__value">{{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }}</span>
<span class="rr-stat__sub">as at {{ savedData.basis.dtlAsOnDate }}</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">{{ savedData.rateType }} rate</span>
<span class="rr-stat__value">{{ savedData.ratePercentage }}%</span>
<span class="rr-stat__sub">as filed</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Required</span>
<span class="rr-stat__value">{{ amount(savedData.requiredAmount) }}</span>
<span class="rr-stat__sub">{{ savedData.ratePercentage }}% of NDTL</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Maintained</span>
<span class="rr-stat__value">{{ amount(savedData.maintainedAmount) }}</span>
<span class="rr-stat__sub">on {{ savedData.basis.holdingsAsOnDate }}</span>
</div>
<div class="rr-stat" [class.rr-stat--good]="savedData.compliant" [class.rr-stat--bad]="!savedData.compliant">
<span class="rr-stat__label">{{ savedData.compliant ? 'Surplus' : 'Shortfall' }}</span>
<span class="rr-stat__value">{{ amount(savedSurplus) }}</span>
<span class="rr-stat__sub">{{ savedData.compliant ? 'Above requirement' : 'Below requirement' }}</span>
</div>
</div>
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
@for (line of savedData.lines; track line.configKey) {
<tr class="rr-line" [class.rr-line--unconfigured]="!line.configured">
<td>
<span class="rr-line__label">{{ line.label }}</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>
@for (account of line.accounts; track account.glAccountId) {
<tr class="rr-subline">
<td>
<span class="rr-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (isPartial(account)) {
<span class="rr-subline__portion">
{{ account.weightPercentage }}% of {{ amount(account.accountBalance) }}
</span>
}
@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>
}
}
</tbody>
</table>
@if (savedData.maintenance; as sm) {
<h4 class="rr-saved__subhead">Daily maintenance as filed — {{ sm.periodLabel || '' }}</h4>
<table class="rr-table">
<thead>
<tr>
<th>Date</th>
<th class="rr-table__num">Required</th>
<th class="rr-table__num">Actual</th>
<th class="rr-table__num">Surplus / (Shortfall)</th>
</tr>
</thead>
<tbody>
@for (day of sm.days; track day.date) {
<tr [class.rr-day--short]="day.amount < sm.dailyMinimumAmount">
<td>{{ day.date }}</td>
<td class="rr-table__num">{{ amount(day.required) }}</td>
<td class="rr-table__num">{{ amount(day.amount) }}</td>
<td class="rr-table__num" [class.rr-neg]="day.surplus < 0">{{ amount(day.surplus) }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
}
@@ -129,6 +129,52 @@ $red: #c5221f;
width: 180px;
}
// ── Tabs ──
.rr-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid $border;
margin-bottom: 16px;
}
.rr-tab {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: $gray-500;
cursor: pointer;
font-family: inherit;
i {
font-size: 12px;
}
&:hover {
color: $gray-900;
}
&--on {
color: $primary;
border-bottom-color: $primary;
}
&__count {
font-size: 10.5px;
font-weight: 700;
background: #e8f0fe;
color: $primary;
border-radius: 20px;
padding: 1px 8px;
}
}
// ── Basis ──
.rr-basis {
@@ -297,7 +343,7 @@ $red: #c5221f;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 10px 10px;
padding: 10px;
border-bottom: 1px solid $border;
white-space: nowrap;
}
@@ -446,6 +492,154 @@ $red: #c5221f;
font-weight: 600;
}
// A shortfall on a day reads as one, rather than as a number with a minus sign in front.
.rr-neg {
color: $red;
font-weight: 600;
}
// ── Saved returns ──
.rr-empty {
padding: 22px;
text-align: center;
color: $gray-500;
font-size: 12.5px;
}
.rr-save-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
&__note {
flex: 1 1 340px;
}
}
.rr-saved {
&__period,
&__meta {
font-size: 11px;
color: $gray-500;
white-space: nowrap;
}
&__status {
width: 170px;
}
&__actions {
white-space: nowrap;
}
&__view {
font-size: 12px !important;
min-width: 0 !important;
color: $primary !important;
}
&__del {
min-width: 0 !important;
padding: 0 8px !important;
color: $gray-500 !important;
&:hover {
color: $red !important;
}
}
&__remarks {
display: block;
margin-top: 4px;
color: $gray-500;
}
&--open td {
background: #e8f0fe;
}
}
// The opened snapshot, in its own section. Tinted and edged so it reads as a different document
// from the live return above it rather than as more of the same page.
.rr-card--saved {
border-color: #c6dafc;
background: #fbfdff;
border-left: 4px solid $primary;
}
.rr-saved-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.rr-saved__subhead {
margin: 20px 0 8px;
font-size: 13px;
font-weight: 600;
color: $gray-900;
}
// ── Print ──
// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. Controls,
// navigation and the saved-returns list are dropped: a printed return is a statement, and a page
// carrying dead buttons reads as a screenshot of software rather than as a filing.
@media print {
.rr-noprint,
.rr-header__actions,
.rr-unmet__link {
display: none !important;
}
.rr-container {
max-width: none;
padding: 0;
}
.rr-card,
.rr-basis,
.rr-stat {
border-color: #999 !important;
box-shadow: none !important;
break-inside: avoid;
}
.rr-card {
overflow: visible !important;
}
// Header rows repeat on every printed page, so a table spilling over a page break stays readable.
.rr-table thead {
display: table-header-group;
}
.rr-table tr {
break-inside: avoid;
}
.rr-toast {
display: none !important;
}
// Backgrounds carry meaning here — a day below the floor is shaded, not merely labelled — so they
// must survive the print engine's default of dropping them to save ink.
.rr-container,
.rr-card,
.rr-basis,
.rr-stat,
.rr-table,
.rr-table th,
.rr-table td {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
.rr-badge {
display: inline-block;
margin-left: 8px;
@@ -479,7 +673,7 @@ $red: #c5221f;
z-index: 1000;
}
@media (max-width: 720px) {
@media (width <= 720px) {
.rr-header__actions {
width: 100%;
}
@@ -21,7 +21,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Saving reference'"
[accountTitle]="productLabel ? productLabel + ' reference' : 'Saving reference'"
[glAccount]="accountingMappings.savingsReferenceAccount"
[withTitle]="'40%'"
>
@@ -72,7 +72,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Saving control'"
[accountTitle]="productLabel ? productLabel + ' control' : 'Saving control'"
[glAccount]="accountingMappings.savingsControlAccount"
[withTitle]="'40%'"
>
@@ -81,7 +81,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Savings transfers in suspense'"
[accountTitle]="productLabel ? 'Transfers in suspense' : 'Savings transfers in suspense'"
[glAccount]="accountingMappings.transfersInSuspenseAccount"
[withTitle]="'40%'"
>
@@ -143,7 +143,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Interest on savings'"
[accountTitle]="productLabel ? 'Interest on ' + productLabel.toLowerCase() + 's' : 'Interest on savings'"
[glAccount]="accountingMappings.interestOnSavingsAccount"
[withTitle]="'40%'"
>
@@ -26,6 +26,8 @@ export class ViewSavingsAccountingDetailsComponent {
@Input() accountingRule: OptionData;
@Input() accountingMappings: any[] = [];
/** Product name for GL row titles (e.g. 'Fixed deposit'); omitted = savings wording. */
@Input() productLabel?: string;
isCashOrAccrualAccounting(): boolean {
if (this.accountingRule) {
+4
View File
@@ -2236,6 +2236,9 @@
"Interest free period": "Interest free period",
"Interest method": "Interest method",
"Interest on savings": "Interest on savings",
"Fixed deposit control": "Fixed deposit control",
"Fixed deposit reference": "Fixed deposit reference",
"Interest on fixed deposits": "Interest on fixed deposits",
"Interest rate": "Interest Rate",
"Interest recalculation compounding on": "Interest recalculation compounding on",
"Interest waived on": "Interest waived on",
@@ -2793,6 +2796,7 @@
"Savings Product": "Savings Product",
"Savings SubType": "Savings SubType",
"Savings transfers in suspense": "Savings transfers in suspense",
"Transfers in suspense": "Transfers in suspense",
"Schedule Date": "Schedule Date",
"Share Details": "Share Details",
"Scheduler Jobs": "Scheduler Jobs",
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260924',
'hash': 'a6e62201'
'version': '260928',
'hash': '6cbb31b5'
},
'allow_switching_backend_instance': true
};