Merge branch 'dev-latest' into feature/bugfix

This commit is contained in:
2026-09-28 17:23:53 +05:30
31 changed files with 2008 additions and 709 deletions
@@ -539,7 +539,7 @@
</div>
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
<!-- #ID -->
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
@@ -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 };
@@ -18,7 +18,7 @@
{{
isDashboardRenewalMode
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
: "Process this Fixed Deposit now -- works whether it's Active or Matured."
: 'Process this matured Fixed Deposit now.'
}}
</p>
</div>
@@ -40,10 +40,10 @@
</div>
} @else {
<form [formGroup]="paymentForm">
@if (isDashboardRenewalMode) {
@if (isMaturedRenewal) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
{{ dashboardRenewalTimingText }}
{{ maturedRenewalTimingText }}
</p>
@if (renewalPreviewLoading) {
@@ -97,7 +97,7 @@
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
isDashboardRenewalMode || !remainderIsApproximate
isMaturedRenewal || !remainderIsApproximate
? ('labels.inputs.Maturity Amount' | translate)
: 'Amount available on the selected date'
}}
@@ -106,7 +106,7 @@
</div>
}
@if (!isDashboardRenewalMode) {
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
@@ -230,7 +230,7 @@
</mat-form-field>
}
@if (!isDashboardRenewalMode) {
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
@@ -133,29 +133,32 @@ export class PaymentDialogComponent implements OnInit {
note: ['']
});
const dashboardRow = this.dashboardRenewalRow;
if (dashboardRow) {
// Computed from the grace window, never picked -- the date field stays in the form (the generic
// submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its picker is
// hidden in this mode (see the template), so it can't be moved off this value.
this.paymentForm
.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;
// Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever
// offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it
// is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the
// exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the
// Dashboard would silently skip crediting interest it genuinely earned.
this.renewalPreviewLoading = true;
this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
next: (preview: any) => {
this.renewalPreview = preview;
this.renewalPreviewLoading = false;
if (preview?.isMatured) {
// Computed from the grace window, never picked -- the date field stays in the form (the
// generic submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its
// picker is hidden once matured (see the template), so it can't be moved off this value.
this.paymentForm
.get('closedOnDate')
.setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate);
this.gapInterestPending = !preview.withinGrace;
}
});
}
},
// Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit),
// only the numeric preview and its date-locking are unavailable, not the action itself.
error: () => {
this.renewalPreviewLoading = false;
}
});
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
@@ -195,13 +198,19 @@ export class PaymentDialogComponent implements OnInit {
return this.data.dashboardRenewal?.row ?? null;
}
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date in this
* mode, since the date itself is no longer visible to explain it. */
get dashboardRenewalTimingText(): string {
const row = this.dashboardRenewalRow;
if (!row) return '';
return row.withinGraceWindow
? `Backdated to its original maturity date (${row.maturityDate}), at the currently active interest rate.`
/** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is
* only ever offered on a Matured account, is every real open of this dialog now, from either entry
* point. Drives the date-locking and gap-interest preview/crediting identically either way. */
get isMaturedRenewal(): boolean {
return !!this.renewalPreview?.isMatured;
}
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once
* matured, since the date itself is no longer visible to explain it. */
get maturedRenewalTimingText(): string {
if (!this.renewalPreview) return '';
return this.renewalPreview.withinGrace
? `Backdated to its original maturity date (${this.renewalPreview.maturityDate}), at the currently active interest rate.`
: `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` +
`credited at the linked account's rate first, then this renews from today.`;
}
@@ -0,0 +1,194 @@
<!--
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="pcd">
<div class="pcd__header">
<div class="pcd__header-left">
<span class="pcd__header-ico"><fa-icon icon="times-circle"></fa-icon></span>
<div class="pcd__header-text">
<h2 class="pcd__title">{{ 'labels.heading.Premature Close' | translate }}</h2>
<p class="pcd__subtitle">
Close {{ data.accountNo ?? 'this account' }} before maturity -- interest is recalculated as of the date below,
usually at a lower, penalty rate.
</p>
</div>
</div>
<button type="button" class="pcd__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content class="pcd__content">
@if (loadFailed) {
<div class="pcd__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not calculate the premature-closure amount for this account. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="prematureCloseForm">
<mat-form-field appearance="outline" class="pcd__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (prematureCloseForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (loadingAmount) {
<div class="pcd__loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Calculating premature-closure amount...</span>
</div>
} @else {
@if (maturityAmount !== null && maturityAmount !== undefined) {
<div class="pcd__preview">
@if (data.depositAmount) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Deposit Amount</span>
<span class="pcd__preview-value">{{ data.depositAmount | formatNumber }}</span>
</div>
}
@if (data.nominalAnnualInterestRate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Contracted Interest Rate</span>
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
</div>
}
@if (data.preClosurePenalApplicable) {
<div class="pcd__preview-row">
<span class="pcd__preview-label"
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
>
<span class="pcd__preview-value">&minus; {{ data.preClosurePenalInterest }}%</span>
</div>
}
@if (interestEarned !== null) {
<div class="pcd__preview-row pcd__preview-row--highlight">
<span class="pcd__preview-label"
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
>
<span class="pcd__preview-value">+ {{ interestEarned | formatNumber }}</span>
</div>
}
<div class="pcd__preview-row pcd__preview-row--total">
<span class="pcd__preview-label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
<span class="pcd__preview-value">{{ maturityAmount | formatNumber }}</span>
</div>
</div>
}
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pcd__option"
role="radio"
[attr.aria-checked]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
[class.pcd__option--active]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pcd__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pcd__option-text">
<span class="pcd__option-title">{{ closureOption.value }}</span>
<span class="pcd__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (prematureCloseForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pcd__option-check"></fa-icon>
}
</button>
}
</div>
@if (
prematureCloseForm.controls.onAccountClosureId?.hasError('required') &&
prematureCloseForm.controls.onAccountClosureId?.touched
) {
<div class="pcd__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (noDestinationAccounts) {
<p class="pcd__hint pcd__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that
pays out in cash instead.
</p>
}
@if (prematureCloseForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (prematureCloseForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
@if (prematureCloseForm.contains('note')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
}
@if (submitError) {
<p class="pcd__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pcd__actions">
<button mat-button mat-dialog-close [disabled]="submitting" class="pcd__btn-cancel">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
class="pcd__btn-submit"
[disabled]="!prematureCloseForm?.valid || submitting || loadFailed || loadingAmount"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -0,0 +1,388 @@
/**
* 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/.
*/
/**
* Premature Close Dialog Styles
* CBSNexora Core Banking System
*
* Same shape/spacing/typography as the Payment dialog (.pmt__*), in this Danger Zone action's own red
* accent on the header and Confirm button, rather than Payment's blue -- an irreversible, usually
* penalty-bearing action reads differently from a routine one, everywhere else stays identical so the
* two dialogs feel like the same product.
*/
.pcd {
display: flex;
flex-direction: column;
height: 100%;
max-height: 92vh;
box-sizing: border-box;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
}
.pcd__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 26px 30px;
background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 55%, #dc2626 100%);
color: #fff;
flex-shrink: 0;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
pointer-events: none;
}
&-left {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
&-ico {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
&-text {
display: flex;
flex-direction: column;
}
&-close {
position: relative;
z-index: 1;
background: rgb(255 255 255 / 14%);
border: none;
color: #fff;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: rgb(255 255 255 / 28%);
transform: rotate(90deg);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.pcd__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
}
.pcd__subtitle {
margin: 5px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 88%);
line-height: 1.4;
}
.pcd__content {
padding: 26px 30px !important;
overflow-y: auto;
flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
}
.pcd__loading {
display: flex;
align-items: center;
gap: 12px;
color: #64748b;
font-size: 13px;
padding: 14px 0 18px;
}
.pcd__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 {
.pcd__preview-value {
color: #b91c1c;
font-weight: 800;
}
}
&--total {
margin: 0 -18px;
padding: 14px 18px;
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border-bottom: none;
border-radius: 0 0 12px 12px;
.pcd__preview-label {
color: #b91c1c;
font-weight: 700;
}
.pcd__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;
}
}
.pcd__section-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #475569;
margin: 10px 0 12px;
}
/* Action Options Grid - 2 Equal Columns */
.pcd__options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
.pcd__option {
display: flex;
align-items: flex-start;
gap: 14px;
text-align: left;
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
cursor: pointer;
font: inherit;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
border-color: #fca5a5;
background: #f8fafc;
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
&--active {
border-color: #dc2626;
background: #fef2f2;
box-shadow: 0 0 0 1.5px #dc2626;
.pcd__option-ico {
background: #dc2626;
color: #fff;
border-color: #dc2626;
}
}
&-ico {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #dc2626;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
transition: all 0.2s ease;
}
&-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
&-title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
}
&-desc {
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
&-check {
color: #dc2626;
font-size: 17px;
position: absolute;
top: 14px;
right: 14px;
}
}
.pcd__option-error {
color: #b91c1c;
font-size: 12px;
font-weight: 600;
margin: -10px 0 14px;
}
.pcd__hint {
display: flex;
align-items: flex-start;
gap: 10px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-left: 4px solid #0284c7;
border-radius: 10px;
color: #0369a1;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
&--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #b45309;
}
}
.pcd__error {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #ef4444;
border-radius: 10px;
color: #b91c1c;
font-size: 13px;
padding: 12px 16px;
margin: 10px 0;
}
.pcd__actions {
padding: 18px 30px !important;
border-top: 1px solid #e2e8f0;
background: #fff;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-shrink: 0;
.pcd__btn-cancel {
border-radius: 10px;
font-weight: 600;
color: #475569;
}
.pcd__btn-submit {
border-radius: 10px;
font-weight: 700;
padding: 0 26px;
height: 42px;
background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%);
color: #fff;
box-shadow: 0 8px 20px rgb(220 38 38 / 30%);
transition: all 0.2s ease;
&:hover:not(:disabled) {
box-shadow: 0 10px 24px rgb(220 38 38 / 40%);
transform: translateY(-1px);
}
&:disabled {
background: #cbd5e1;
box-shadow: none;
color: #64748b;
}
}
}
@@ -0,0 +1,218 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms';
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's
* nothing to renew): pay out in cash, or transfer the pre-maturity balance to a savings account. Same
* `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone
* action's own red accent rather than Payment's blue, since it's an irreversible, usually
* penalty-bearing action rather than a routine one.
* <p>
* The premature amount is date-dependent (unlike Payment's one-shot template load): every change to
* Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did.
*/
@Component({
selector: 'mifosx-premature-close-dialog',
templateUrl: './premature-close-dialog.component.html',
styleUrls: ['./premature-close-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
CdkTextareaAutosize,
FaIconComponent,
FormatNumberPipe
]
})
export class PrematureCloseDialogComponent implements OnInit {
/** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
};
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
data: {
accountId: number | string;
accountNo?: string;
depositAmount?: number;
nominalAnnualInterestRate?: number;
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: number = null;
loadingAmount = true;
loadFailed = false;
submitting = false;
submitError: string = null;
minDate = new Date(2000, 0, 1);
maxDate: Date;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.prematureCloseForm = this.formBuilder.group({
closedOnDate: [
this.maxDate,
Validators.required
]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
// Same date the form already starts with -- without this the amount/options never load unless the
// maker happens to touch the date picker, which the full-page form this replaces required too.
this.calculatePrematureAmount(this.maxDate);
}
private calculatePrematureAmount(date: Date): void {
this.loadingAmount = true;
this.loadFailed = false;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
.subscribe({
next: (response: any) => {
this.savingsAccountsData = response.savingsAccounts ?? [];
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
this.maturityAmount = response.maturityAmount;
this.loadingAmount = false;
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('note', new UntypedFormControl(''));
this.prematureCloseForm
.get('onAccountClosureId')
.valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
}
},
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out.
error: () => {
this.loadingAmount = false;
this.loadFailed = true;
}
});
}
/** Adds/removes the destination-account control to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) {
if (!this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
this.prematureCloseForm.removeControl('toSavingsAccountId');
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** The gap between the deposit and the computed maturity amount -- the actual interest this premature
* closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
get interestEarned(): number | null {
if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) {
return null;
}
return Number(this.maturityAmount) - Number(this.data.depositAmount);
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
!this.loadingAmount &&
this.prematureCloseForm?.contains('toSavingsAccountId') &&
this.savingsAccountsData.length === 0
);
}
iconFor(id: number): string {
return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle';
}
descriptionFor(id: number): string {
return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
}
selectClosureType(id: number): void {
this.prematureCloseForm.get('onAccountClosureId').setValue(id);
this.prematureCloseForm.get('onAccountClosureId').markAsTouched();
}
submit(): void {
const formValue = { ...this.prematureCloseForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.closedOnDate instanceof Date) {
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
}
const data = { ...formValue, dateFormat, locale };
this.submitting = true;
this.submitError = null;
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
.subscribe({
next: () => {
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'This account could not be prematurely closed. Please review the details and try again.';
}
});
}
}
@@ -17,6 +17,7 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
@@ -238,7 +239,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Undo Activation':
case 'Add Charge':
case 'Withdrawn by Client':
case 'Premature Close':
case 'Deposit':
case 'Withdrawal':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
@@ -267,12 +267,17 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Payment':
this.openPaymentDialog();
break;
case 'Premature Close':
this.openPrematureCloseDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
* Opens the Payment action -- only offered once the FD has actually Matured (see
* FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the
* exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's
* openRenewDialog), just without dashboardRenewal data, so its date picker stays visible.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
@@ -293,6 +298,34 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
* matured yet.
*/
private openPrematureCloseDialog(): void {
const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo,
// calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached --
// these come from the account's own data (already loaded for this page) so the dialog can show
// the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number.
depositAmount: this.fixedDepositsAccountData.depositAmount,
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -110,7 +110,18 @@ export class FixedDepositsButtonsConfiguration {
setOptions(status: string) {
switch (status) {
case 'Active':
this.optionArray = [
{
name: 'Post Interest'
},
{
name: 'Add Charge'
}
];
break;
case 'Matured':
// Payment only makes sense once the deposit has actually matured -- an Active FD that needs
// closing early already has Premature Close for that.
this.optionArray = [
{
name: 'Post Interest'
@@ -24,9 +24,6 @@
@if (actions['Add Charge']) {
<mifosx-add-charge-fixed-deposits-account></mifosx-add-charge-fixed-deposits-account>
}
@if (actions['Premature Close']) {
<mifosx-premature-close-fixed-deposits-account></mifosx-premature-close-fixed-deposits-account>
}
@if (actions['Close']) {
<mifosx-close-fixed-deposits-account></mifosx-close-fixed-deposits-account>
}
@@ -15,7 +15,6 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits
import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component';
import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component';
import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component';
import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
UndoApprovalFixedDepositsAccountComponent,
WithdrawByClientFixedDepositsAccountComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
FixedDepositsCashTransactionComponent
]
@@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': boolean;
'Undo Activation': boolean;
'Add Charge': boolean;
'Premature Close': boolean;
'Withdrawn by Client': boolean;
Withdrawal: boolean;
Deposit: boolean;
@@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': false,
'Undo Activation': false,
'Add Charge': false,
'Premature Close': false,
'Withdrawn by Client': false,
Withdrawal: false,
Deposit: false
@@ -1,105 +0,0 @@
<!--
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="container">
<mat-card>
<form [formGroup]="prematureCloseAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (prematureCloseAccountForm.controls.maturityAmount) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Maturity Amount' | translate }}</mat-label>
<input type="number" matInput formControlName="maturityAmount" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('onAccountClosureId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Action' | translate }}</mat-label>
<mat-select required formControlName="onAccountClosureId">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<mat-option [value]="closureOption.id">
{{ closureOption.value }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('toSavingsAccountId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('transferDescription')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('note')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
</div>
</mat-card-content>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" [disabled]="!prematureCloseAccountForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
@@ -1,11 +0,0 @@
/**
* 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/.
*/
.container {
max-width: 37rem;
}
@@ -1,179 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
Validators,
UntypedFormControl,
ReactiveFormsModule
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Premature Close Fixed Deposits Account Component
*/
@Component({
selector: 'mifosx-premature-close-fixed-deposits-account',
templateUrl: './premature-close-fixed-deposits-account.component.html',
styleUrls: ['./premature-close-fixed-deposits-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseFixedDepositsAccountComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Premature Close FD Account form. */
prematureCloseAccountForm: UntypedFormGroup;
/** Savings Account Data */
savingsAccountsData: any;
/** On account Closure Options */
onAccountClosureOptions: any;
/** Fixed Deposits Account Id */
accountId: any;
/** Form submission event */
isSubmitted = false;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
}
/**
* Creates the premature close fd account form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createPrematureCloseAccountForm();
this.buildDependencies();
}
/**
* Creates the premature close fd account form.
*/
createPrematureCloseAccountForm() {
this.prematureCloseAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
});
}
/**
* Subscribes to value changes of parent control.
*/
buildDependencies() {
this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => {
if (!this.isSubmitted) {
this.calculatePrematureAmount(value);
}
});
}
/**
* Calculates prematue amount based on closed on date.
* @param {Date} date Premature Close Date
*/
calculatePrematureAmount(date: Date) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data)
.subscribe((response: any) => {
this.savingsAccountsData = response.savingsAccounts;
this.onAccountClosureOptions = response.onAccountClosureOptions;
this.prematureCloseAccountForm.addControl(
'maturityAmount',
new UntypedFormControl({ value: '', disabled: true })
);
this.prematureCloseAccountForm.addControl(
'onAccountClosureId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('note', new UntypedFormControl(''));
this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount);
this.addTransferDetails();
});
}
/**
* Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly.
*/
addTransferDetails() {
this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => {
if (id === 200) {
this.prematureCloseAccountForm.addControl(
'toSavingsAccountId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl(''));
} else {
this.prematureCloseAccountForm.removeControl('toSavingsAccountId');
this.prematureCloseAccountForm.removeControl('transferDescription');
}
});
}
/**
* Submits the form and premature closes the fd account,
* if successful redirects to the fd account.
*/
submit() {
const prematureCloseAccountFormData = this.prematureCloseAccountForm.value;
this.isSubmitted = true;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate;
if (prematureCloseAccountFormData.closedOnDate instanceof Date) {
prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat);
}
const data = {
...prematureCloseAccountFormData,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -35,7 +35,6 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran
import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component';
import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component';
import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component';
import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component';
import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component';
@@ -78,7 +77,6 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account-
WaiveChargeDialogComponent,
InactivateChargeDialogComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
CreateFixedDepositAccountComponent,
FixedDepositAccountDetailsStepComponent,
@@ -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
};