Compare commits

..
Author SHA1 Message Date
keshav 5ab4e9eaf0 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-05 12:41:38 +05:30
keshav 4fc6784695 feat: interest posting 2026-10-05 12:39:14 +05:30
nitesh 174a2e7b6b fix(accounting,deposits): allow future dates in premature close and GL search, fix FD/RD accounting mappings 2026-10-04 16:02:15 +05:30
nitesh 9f7d9d28fd feat: Fixed Deposit - premature close, payment dialog, account actions, translations and env updates 2026-10-04 09:12:06 +05:30
keshav 1ffac4b1e5 fix(savings-product): move savings interest paid to expenses mapping 2026-10-01 14:01:24 +05:30
niteshandClaude Sonnet 5 4dee1db360 Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Brings in partner's RBI Reserve / Form 1 & 9 returns UI and interest
posting reports, alongside today's FD premature-closure work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:22:39 +05:30
niteshandClaude Sonnet 5 0c6ebfdc1a Merge branch 'feature/bugfix' into dev-latest
Brings in partner's "Enforce same-user maker-checker restriction" and
related client-KYC-step / product-edit-diff-dialog work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:21:19 +05:30
keshavandClaude Opus 5.5 b6994c0b76 feat(rbi-reserve,savings): Form 1/Form 9 returns UI, branch-wise interest posting reports and summary
- Reports > Interest Posting Reports: per branch and product, generate/regenerate
  at any time, approve; branch column, HO-on-behalf marker, posted txn per account
- Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export
- Day Close warns and disables Close Day while the branch's interest report is unapproved
- Savings product accounting: Interest On Savings (expense), Savings Interest Payable
  and Paid (liability rows, required)
- RBI Form 1 statutory and Form 9 return screens, reserve config updates
- Interest posting report GETs exempted from the response cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:20:01 +05:30
niteshandClaude Sonnet 5 4f02f24aae feat(deposits): RBI premature closure compliance UI, renewal-history status fix
Premature Close dialog: adds the Closure Reason dropdown, the explicit
Waive Penalty checkbox, a tenure/date preview panel, and an "Effective
Rate for this closure" display so the maker sees exactly what rate and
penalty treatment is about to apply.

Renewal History tab: the latest term's card kept showing an ACTIVE badge
even after the account was prematurely closed, since the archived
renewal-status column only ever updates on an actual renewal rollover.
For the last card only, status now cross-checks the account's own live
status instead. "Projected Maturity" now reads "Closed At" once that
term has prematurely closed, rather than implying it's still a projection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:19:37 +05:30
niteshandClaude Sonnet 5 74a51a0626 fix(products): show named GL account changes in the pending-edit diff dialog
otherGlMappings (the nested document behind Savings/Current/FD/RD Interest
Payable/Paid and similar named accounts) was excluded from the generic
field diff entirely, and the backend was silently dropping its value
anyway (see the paired backend fix). Now that the backend correctly
stages this field, diff it explicitly: find each reserved account name
across original.otherGlMappingsMap and proposed.otherGlMappings, resolve
the GL code to a readable name, and show a row only when it actually
moved -- same convention as every other field in this dialog, instead of
a raw JSON blob or nothing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:40:10 +05:30
64 changed files with 6113 additions and 487 deletions
@@ -279,7 +279,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,13 +353,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -368,7 +362,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.maxDate = null;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
@@ -17,7 +17,12 @@
</p>
</div>
</div>
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
<button
class="bor-btn bor-btn--xls"
type="button"
[disabled]="exporting || !filtered.length"
(click)="exportExcel()"
>
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
@@ -132,7 +137,9 @@
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td>
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
</td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
@@ -107,11 +107,26 @@
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="startClose(openDay)"
[disabled]="interestPending.length > 0"
>
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
@if (interestPending.length > 0) {
<div class="bd-interest-alert">
<mat-icon>warning</mat-icon>
<span>
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
closing the day.
</span>
</div>
}
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
@@ -372,3 +372,30 @@
color: #d97706;
}
}
// Shown under the open day while a savings interest posting report due today is unapproved.
.bd-interest-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0 12px;
padding: 10px 14px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
color: #92400e;
font-size: 13px;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
@@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit {
return this.totalOf(this.closeCounts);
}
/**
* Savings products whose interest posting report is due today and not yet approved. The close is
* refused by the backend while any is pending; this only says so before the user tries.
*/
interestPending: string[] = [];
private checkInterestReports(): void {
if (!this.openDay?.businessDate) {
this.interestPending = [];
return;
}
this.service
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
}
load(): void {
this.loading = true;
this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
this.checkInterestReports();
});
this.service
+17
View File
@@ -205,6 +205,23 @@ export class CashierService {
return this.http.get('/branchday', { params });
}
/**
* The savings products whose interest posting report is due for the date and not yet approved —
* the backend refuses to close any branch's day while one is pending.
*/
interestDayCloseCheck(
date?: string,
officeId?: number
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
'/savingsinterestpostingreports/day-close-check',
{ params }
);
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
@@ -56,8 +56,8 @@
<mat-icon>info_outline</mat-icon>
<div>
@if (configMissing) {
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under
Organization › TDS Configuration.
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
› TDS Configuration.
} @else if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
@@ -477,7 +477,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualIncome:
v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
@@ -522,7 +523,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -769,7 +770,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -930,7 +931,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
+15 -1
View File
@@ -31,7 +31,21 @@ export class CacheInterceptor implements HttpInterceptor {
* underlying GL mapping changed — the request was answered from cachedData and the backend was
* never hit again. Matched as a substring of the request URL.
*/
private static readonly NEVER_CACHE = ['/rbireservereturn', '/rbireserveconfig', '/tdsconfig', '/tds/deposit-interest'];
private static readonly NEVER_CACHE = [
'/rbireservereturn',
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday'
];
private isCacheable(url: string): boolean {
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
+16 -3
View File
@@ -663,6 +663,12 @@
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
@@ -2076,6 +2082,8 @@
at month end. -->
<div class="gl-group">
<div class="gl-group-title">Reserve Returns</div>
<!-- The statutory form is a tab on Form 1 rather than an entry of its own: two menu
items for one return had a filer guessing which one they were looking at. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 1 CRR Cash Reserve Ratio')"
@@ -2084,13 +2092,18 @@
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 1 — CRR (fortnightly)
</a>
<!-- Form 9 (SLR) has no entry of its own. Section 24 is settled on the statutory Form 1,
at Part C, on the same liabilities Part B charges the cash reserve on — so the SLR
position is read there rather than on a second screen that computed it from a
differently drawn base and disagreed. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 9 SLR Statutory Liquidity Ratio')"
routerLink="/rbi/form9"
[class.hidden]="!matchesRbiSearch('Form IX R408 Return on Assets and Liabilities balance sheet')"
routerLink="/rbi/form9-return"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 9 — SLR (monthly)
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form IX — Assets &amp;
Liabilities (R408)
</a>
</div>
<!-- CIMS / XBRL Returns -->
@@ -268,7 +268,7 @@
<span class="nx-row__label">OD Interest Charged</span>
<span class="nx-row__value nx-row__value--amount">
{{
(currentAccountData.summary.totalOverdraftInterestDerived || 0)
currentAccountData.summary.totalOverdraftInterestDerived || 0
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
@@ -24,6 +24,7 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -97,6 +98,7 @@ export class PaymentDialogComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
@@ -375,7 +377,16 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
next: (response: any) => {
// Parked in the Checker Inbox rather than executed -- see the same handling in
// PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -51,12 +51,53 @@
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Closure Reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of closureReasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loadingAmount) {
<div class="pcd__loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Calculating premature-closure amount...</span>
</div>
} @else {
@if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
<div class="pcd__preview pcd__preview--tenure">
@if (data.activatedOnDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Term Started</span>
<span class="pcd__preview-value">{{ activatedOnDateDisplay }}</span>
</div>
<div class="pcd__preview-row">
<span class="pcd__preview-label">Tenure Completed (this term)</span>
<span class="pcd__preview-value"
>{{ tenureElapsedDisplay }} <span class="pcd__preview-sub">({{ daysElapsed }} days)</span></span
>
</div>
}
@if (data.maturityDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Scheduled Maturity Date</span>
<span class="pcd__preview-value"
>{{ maturityDateDisplay }}
<span class="pcd__preview-sub">({{ daysRemainingToMaturity }} days left)</span></span
>
</div>
}
@if (data.currentBalance !== undefined && data.currentBalance !== null) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Balance (as of today)</span>
<span class="pcd__preview-value">{{ data.currentBalance | formatNumber }}</span>
</div>
}
</div>
}
@if (maturityAmount !== null && maturityAmount !== undefined) {
<div class="pcd__preview">
@if (data.depositAmount) {
@@ -71,12 +112,27 @@
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
</div>
}
@if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
<div class="pcd__preview-row pcd__preview-row--highlight">
<span class="pcd__preview-label">Effective Rate for this closure</span>
<span class="pcd__preview-value">{{ effectiveInterestRate }}%</span>
</div>
}
@if (data.preClosurePenalApplicable) {
<div class="pcd__preview-row">
<div
class="pcd__preview-row"
[class.pcd__preview-row--waived]="prematureCloseForm.get('waivePreClosurePenalty').value"
>
<span class="pcd__preview-label"
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
>
<span class="pcd__preview-value">&minus; {{ data.preClosurePenalInterest }}%</span>
<span class="pcd__preview-value">
@if (prematureCloseForm.get('waivePreClosurePenalty').value) {
<span class="pcd__penalty-waived-tag">Waived</span>
} @else {
&minus; {{ data.preClosurePenalInterest }}%
}
</span>
</div>
}
@if (interestEarned !== null) {
@@ -84,7 +140,9 @@
<span class="pcd__preview-label"
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
>
<span class="pcd__preview-value">+ {{ interestEarned | formatNumber }}</span>
<span class="pcd__preview-value"
>{{ interestEarned >= 0 ? '+' : '' }} {{ interestEarned | formatNumber }}</span
>
</div>
}
<div class="pcd__preview-row pcd__preview-row--total">
@@ -94,6 +152,26 @@
</div>
}
@if (data.preClosurePenalApplicable) {
@if (isAutoWaivedReason) {
<div class="pcd__waive-locked">
<fa-icon icon="check-circle" class="m-r-10"></fa-icon>
<span>
Penalty automatically waived -- <strong>{{ selectedReasonLabel }}</strong> always waives the
pre-closure penalty, regardless of this checkbox.
</span>
</div>
} @else {
<mat-checkbox formControlName="waivePreClosurePenalty" class="pcd__waive-checkbox">
Waive the pre-closure penalty for this closure
<span class="pcd__waive-hint">
Interest recalculates at the full contracted rate instead of the penalty rate above. This does not
change the product's own penalty setting -- it applies to this one closure only.
</span>
</mat-checkbox>
}
}
<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) {
@@ -129,8 +207,8 @@
@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.
This customer has no active savings account to transfer into. Premature closure has to be paid into a
savings account, so open one for this customer before closing this deposit.
</p>
}
@@ -153,6 +231,16 @@
</mat-form-field>
}
@if (prematureCloseForm.contains('toGlAccountId')) {
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
[inputLabel]="'Transfer to GL Account'"
>
</mifosx-gl-account-selector>
}
@if (prematureCloseForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
@@ -204,6 +204,86 @@
font-weight: 700;
color: #0f172a;
}
&-sub {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 500;
color: #94a3b8;
margin-left: 4px;
}
}
.pcd__preview--tenure {
margin-bottom: 14px;
}
.pcd__preview-row--waived {
.pcd__preview-label {
color: #16a34a;
}
}
.pcd__penalty-waived-tag {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #15803d;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 999px;
padding: 3px 10px;
}
.pcd__waive-checkbox {
display: flex;
align-items: flex-start;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
margin: 0 0 18px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
::ng-deep .mdc-form-field {
align-items: flex-start;
}
::ng-deep .mdc-checkbox {
margin-top: -9px;
}
}
.pcd__waive-locked {
display: flex;
align-items: flex-start;
gap: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 4px solid #16a34a;
border-radius: 10px;
color: #15803d;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
strong {
color: #166534;
}
}
.pcd__waive-hint {
display: block;
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-top: 3px;
line-height: 1.4;
}
.pcd__section-label {
@@ -23,13 +23,19 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators }
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { GLAccount } from 'app/shared/models/general.model';
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
* nothing to renew): transfer the pre-maturity balance to a savings account, or post it to a GL account.
* There is deliberately no cash payout -- the backend does not offer or accept one for a premature
* closure, so the proceeds always stay traceable to an 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.
@@ -49,24 +55,51 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatDialogClose,
CdkTextareaAutosize,
FaIconComponent,
FormatNumberPipe
FormatNumberPipe,
GlAccountSelectorComponent
]
})
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 TRANSFER_TO_GL_ID = 600;
private static readonly ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book'
};
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'
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account'
};
/**
* Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
* four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
* discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
* to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
* are gated because they are the product's own opt-in facilities (see SavingsProduct
* deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
* a maker pick it to bypass a penalty that product's terms don't actually exempt.
*/
private static readonly NORMAL_REASON_ID = 100;
private static readonly ALL_CLOSURE_REASONS: {
id: number;
label: string;
autoWaived: boolean;
gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
}[] = [
{ id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
{ id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
{ id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
{ id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
{ id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
];
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
data: {
accountId: number | string;
@@ -76,16 +109,29 @@ export class PrematureCloseDialogComponent implements OnInit {
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
deceasedSettlement?: boolean;
noPenaltyNomineeHeirClosure?: boolean;
activatedOnDate?: any;
maturityDate?: any;
currentBalance?: number;
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private accountingService = inject(AccountingService);
private alertService = inject(AlertService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
/** Postable GL accounts, loaded once -- only read when the GL action is chosen. */
glAccountsData: GLAccount[] = [];
maturityAmount: number = null;
/** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
* penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
* account's unmodified contracted rate. Read fresh on every recalculation. */
effectiveInterestRate: number = null;
loadingAmount = true;
loadFailed = false;
submitting = false;
@@ -99,17 +145,66 @@ export class PrematureCloseDialogComponent implements OnInit {
closedOnDate: [
this.maxDate,
Validators.required
]
],
// Both always present (not added conditionally like onAccountClosureId below) so submit()
// always carries a value, even for a product with no penalty configured at all -- the backend
// ignores them there anyway, since there is nothing to waive.
prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
waivePreClosurePenalty: [false]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
// Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
// should see the actual amount they're about to confirm, not the penalty-applied one, the moment
// they tick the box.
this.prematureCloseForm
.get('waivePreClosurePenalty')
.valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
// Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
// like it, recalculates the preview so the amount shown always matches the reason selected.
this.prematureCloseForm
.get('prematureClosureReasonId')
.valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
// 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);
}
/** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
(reason) => !reason.gatedBy || this.data[reason.gatedBy]
);
}
/** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
* hidden in favour of a plain statement of that fact when this is true (see the template). */
get isAutoWaivedReason(): boolean {
return !!this.selectedReason?.autoWaived;
}
get selectedReasonLabel(): string {
return this.selectedReason?.label ?? '';
}
private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
}
private onClosureReasonChange(reasonId: number): void {
const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
if (reason?.autoWaived) {
// emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
// change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
// and the amount would be calculated twice for one reason change.
this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
}
this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
}
private calculatePrematureAmount(date: Date): void {
this.loadingAmount = true;
this.loadFailed = false;
@@ -118,7 +213,9 @@ export class PrematureCloseDialogComponent implements OnInit {
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
locale,
prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
@@ -127,6 +224,7 @@ export class PrematureCloseDialogComponent implements OnInit {
this.savingsAccountsData = response.savingsAccounts ?? [];
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
this.maturityAmount = response.maturityAmount;
this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
this.loadingAmount = false;
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
@@ -144,19 +242,50 @@ export class PrematureCloseDialogComponent implements OnInit {
});
}
/** Adds/removes the destination-account control to match what the chosen type needs. */
/** Adds/removes the destination 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 {
const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID;
const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID;
if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
} else if (!toSavings) {
this.prematureCloseForm.removeControl('toSavingsAccountId');
}
if (toGl) {
if (!this.prematureCloseForm.contains('toGlAccountId')) {
this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required));
}
this.loadGlAccounts();
} else {
this.prematureCloseForm.removeControl('toGlAccountId');
}
// Both destinations carry the description through -- to the account transfer, or to the narration on
// the journal entry that credits the GL account.
if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) {
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
} else if (!toSavings && !toGl) {
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
/** The GL selector takes a control instance, not a control name. */
get toGlAccountControl(): UntypedFormControl {
return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl;
}
/** 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 {
@@ -166,6 +295,94 @@ export class PrematureCloseDialogComponent implements OnInit {
return Number(this.maturityAmount) - Number(this.data.depositAmount);
}
get activatedOnDateDisplay(): string {
return this.formatFullDate(this.data.activatedOnDate);
}
get maturityDateDisplay(): string {
return this.formatFullDate(this.data.maturityDate);
}
/**
* The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
* Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
* clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
* advances it), and the backend's interest calculation is measured from THIS date, not today's actual
* date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
* interest the backend actually computed for the same closure -- both must read off the same clock.
*/
private get referenceDate(): Date {
const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
return picked instanceof Date ? picked : this.maxDate;
}
/** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
* closure date is somehow before it, rather than a negative. */
get daysElapsed(): number | null {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
}
/** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
* than negative, since this dialog is for a still-Active account closing early, not a matured one. */
get daysRemainingToMaturity(): number | null {
const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
if (!maturity) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
}
/** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
* count that is hard to read for a multi-year FD. */
get tenureElapsedDisplay(): string {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return '—';
}
const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} yr${years === 1 ? '' : 's'}`);
}
if (months > 0) {
parts.push(`${months} mo${months === 1 ? '' : 's'}`);
}
if (days > 0 || parts.length === 0) {
parts.push(`${days} day${days === 1 ? '' : 's'}`);
}
return parts.join(', ');
}
private formatFullDate(value: any): string {
if (!value) {
return '—';
}
const parsed = this.dateUtils.parseDate(value);
// "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
// account's own configured short date format elsewhere in the app.
return this.dateUtils.formatDate(parsed, 'd MMMM y');
}
private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
let years = end.getFullYear() - start.getFullYear();
let months = end.getMonth() - start.getMonth();
let days = end.getDate() - start.getDate();
if (days < 0) {
months -= 1;
const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
days += daysInPreviousMonth;
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
@@ -201,7 +418,18 @@ export class PrematureCloseDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
.subscribe({
next: () => {
next: (response: any) => {
// A command on a maker-checker permission is parked in the Checker Inbox, not executed:
// the platform rolls its transaction back and still answers HTTP 200 with
// rollbackTransaction: true. Without saying so, the dialog closes and the account reloads
// unchanged -- which reads as "nothing happened" rather than "waiting for approval".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -30,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
@@ -74,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
@@ -298,6 +300,18 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* The start date of the term currently running -- the last entry in renewalHistory, which is ordered
* oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
* an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
* in that case anyway.
*/
private get currentTermStartDate(): any {
const history = this.fixedDepositsAccountData?.renewalHistory;
const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
}
/**
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
* matured yet.
@@ -316,7 +330,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
// Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
// facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
// a product that never enabled them shouldn't offer a reason implying it did.
deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
// Tenure context (current term's start date, maturity date, current balance) so the dialog can
// show how far into THIS term the account is and how much is actually sitting in it today, not
// just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
// term's own start (see currentTermStartDate), not the account's original activation -- a
// renewed FD's interest/tenure runs from the renewal, and showing the original activation date
// here would make an account renewed days ago look like it has been running for months.
activatedOnDate: this.currentTermStartDate,
maturityDate: this.fixedDepositsAccountData.maturityDate,
currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
@@ -393,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
.subscribe(() => {
.subscribe((commandResponse: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with
// rollbackTransaction: true), so the reload below shows no new interest posting.
if (commandResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.'
});
}
this.reload();
});
}
@@ -41,8 +41,8 @@
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
@if (isTermActive(term, last)) {
<span class="rh-node__pulse"></span>
}
</span>
@@ -51,15 +51,15 @@
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card" [class.rh-card--active]="isTermActive(term, last)">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
<span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
<fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
{{ termStatusLabel(term, last) }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
@@ -95,7 +95,7 @@
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
@@ -106,9 +106,7 @@
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
@@ -183,6 +183,51 @@ export class RenewalHistoryTabComponent implements OnInit {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* Whether one term's card should show as "still running". `term.renewalstatus` only ever flips from
* ACTIVE to MATURED when a term rolls into its successor (see FixedDepositRenewalHistory#markMatured
* on the backend) -- a premature closure never touches that row, so the latest term's archived status
* would read ACTIVE forever even after the whole account has closed. For the LAST term only, this
* cross-checks the account's own live status instead of trusting the archived flag; every earlier
* term is reliably matured already (something renewed past it), so its own renewalstatus is
* trustworthy as-is and doesn't need the live account status at all.
*/
isTermActive(term: any, isLast: boolean): boolean {
if (isLast && this.fixedDepositsAccountData?.status) {
return !!this.fixedDepositsAccountData.status.active;
}
return term?.renewalstatus === 'ACTIVE';
}
/** Badge text for one term's card -- see {@link isTermActive} for why the last term needs its own check. */
termStatusLabel(term: any, isLast: boolean): string {
if (this.isTermActive(term, isLast)) {
return 'Active';
}
if (isLast && this.fixedDepositsAccountData?.status?.prematureClosed) {
return 'Premature Closed';
}
return 'Matured';
}
/**
* Label for the maturity/value figure -- "Projected Maturity" only makes sense while the term is
* still genuinely running towards that projection. `term.maturedamount` (the archived settled value)
* is only ever written when a term rolls into its successor, same gap as {@link isTermActive}: a
* premature closure settles the account without ever touching that column, so the last term would
* keep reading as a live projection forever even though the figure showing is already the final,
* settled one (see {@link maturityFor}).
*/
maturityLabel(term: any, isLast: boolean): string {
if (term?.maturedamount != null) {
return 'Matured At';
}
if (isLast && !this.isTermActive(term, isLast)) {
return 'Closed At';
}
return 'Projected Maturity';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -98,17 +100,31 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true).
// The account is therefore still pending, so chaining activation onto it would be applied to an
// unapproved account -- stop here and say what is actually waiting.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This approval has been sent for checker approval. The account stays pending until a checker approves it.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
return;
}
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -21,6 +21,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -43,6 +44,7 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -139,8 +141,18 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'close', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'close', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the deposit reloads unchanged -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Closure has been sent for checker approval. The deposit is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -97,8 +99,19 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'reject', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'reject', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Rejection has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -12,6 +12,7 @@ import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angu
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { AlertService } from 'app/core/alert/alert.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
@@ -35,6 +36,7 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Undo Approval Fixed Deposits Account form. */
undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
@@ -87,12 +89,30 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
if (this.undoAction === 'Undo Activation') {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe(() => {
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
} else {
this.savingsService.executeSavingsAccountCommand(this.accountId, this.undoCommand, data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.savingsService
.executeSavingsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
/**
* Both undo commands sit on maker-checker permissions, so either can come back parked in the
* Checker Inbox rather than executed -- HTTP 200 with rollbackTransaction: true, and the account
* reloaded unchanged. Without this the navigation away reads as "it worked".
*/
private notifyIfSentForApproval(response: any): void {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: `${this.undoAction} has been sent for checker approval. The account is unchanged until a checker approves it.`
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -99,7 +101,16 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data)
.subscribe(() => {
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Withdrawal has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -0,0 +1,322 @@
<!--
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="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Reports</h2>
<div class="ipr-head-s">
Each branch gets its own report for each savings product, generated automatically at 12:00 PM on the posting
date and open to generating or regenerating at any time. The branch approves it, and its approved interest is
posted when the branch closes the day — it cannot close until then. The head office closes last.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
Posting date
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon> Preview interest (today)
</button>
</div>
</div>
@if (isHeadOffice) {
<div class="ipr-tabs">
<button class="ipr-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon> My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="ipr-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon> Other branches
</button>
</div>
@if (tab === 'others') {
<div class="ipr-alert ipr-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Each branch generates and approves its own reports. The head office can act here only for a branch that has
not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
</div>
</div>
}
}
@if (showPreview) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Interest preview — earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each account would be paid if interest were posted today. View only — nothing is saved, approved or
posted.
</div>
</div>
<div class="ipr-total">
<span>Total so far</span>
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} account(s)</span>
</div>
</div>
<div class="ipr-preview-actions">
<button class="ipr-btn ipr-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon> Refresh
</button>
<button
class="ipr-btn ipr-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
<button class="ipr-btn ipr-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon> Close</button>
</div>
@if (previewLoading) {
<div class="ipr-empty">Calculating interest up to today…</div>
} @else {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (g of previewGroups; track g.officeName + g.productName) {
<tr>
<td>{{ g.officeName }}</td>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>{{ g.postingDate }}</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">₹{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="ipr-empty">No savings account has interest accrued in its current cycle.</td>
</tr>
}
</tbody>
</table>
@if (previewRows.length > 0) {
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Balance</th>
<th class="num">Rate %</th>
<th>Cycle</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.savingsAccountId) {
<tr>
<td>{{ r.officeName }}</td>
<td>{{ r.productName }}</td>
<td>{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td class="num">₹{{ r.balance | number: '1.2-2' }}</td>
<td class="num">{{ r.rate | number: '1.2-2' }}</td>
<td class="muted">{{ r.cycleStart }} → {{ r.postingDate }}</td>
<td class="num">₹{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
}
}
</div>
}
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (notice) {
<div class="ipr-alert ipr-alert--ok">
<fa-icon icon="check"></fa-icon>
<div>{{ notice }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are approved.</div>
</div>
}
@if (due.length > 0) {
<div class="ipr-alert ipr-alert--warn ipr-due">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div class="ipr-due-body">
<div>Interest is due but no report has been generated yet for:</div>
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="ipr-due-row">
<strong>{{ d.productName }}</strong> <span>· {{ d.officeName }} · {{ d.postingDate }}</span>
@if (d.canAct) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> {{ tab === 'others' ? 'Generate on behalf' : 'Generate report' }}
</button>
} @else if (d.blockedReason) {
<span class="ipr-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
</div>
}
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Total interest</th>
<th>Status</th>
<th>Generated</th>
<th>Approved</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of reports; track r.id) {
<tr [class.is-selected]="selected?.id === r.id" (click)="open(r)">
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td>{{ r.postingDate }}</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
@if (r.failureReason) {
<div class="ipr-fail" [title]="r.failureReason">Posting failed: {{ r.failureReason }}</div>
}
</td>
<td class="muted">
{{ r.generatedBy || 'System' }} · {{ r.generationMode }}<br />{{ r.generatedAt | date: 'dd MMM, HH:mm' }}
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="actions" (click)="$event.stopPropagation()">
@if (canRegenerate(r)) {
<button class="ipr-btn ipr-btn--slim" (click)="regenerate(r)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> Regenerate
</button>
}
@if (canApprove(r)) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon> {{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
}
@if (!r.canAct && r.blockedReason && (r.status === 'GENERATED' || r.status === 'APPROVED')) {
<span class="ipr-blocked">{{ r.blockedReason }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report for {{ postingDate || 'this date' }}. Reports are generated at 12:00 PM on a
product's posting date.
}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (selected) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>{{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }}</h3>
<div class="muted">
Dr {{ selected.expenseGlCode }} {{ selected.expenseGlName }} → Cr {{ selected.payableGlCode }}
{{ selected.payableGlName }}; Dr {{ selected.payableGlCode }} → Cr {{ selected.paidGlCode }}
{{ selected.paidGlName }}; then Dr {{ selected.paidGlCode }} → Cr each account below
</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ selected.accountCount }} account(s)</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Interest</th>
<th class="num">Balance before posting</th>
<th class="num">Balance after posting</th>
<th>Posted txn</th>
</tr>
</thead>
<tbody>
@for (l of selected.lines; track l.savingsAccountId) {
<tr>
<td>{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="num">₹{{ l.interestAmount | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceBefore | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceAfter | number: '1.2-2' }}</td>
<td class="muted">{{ l.savingsTransactionId ? '#' + l.savingsTransactionId : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,327 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
--i-navy: #1a237e;
--i-blue: #1d4ed8;
--i-blue-l: #eff6ff;
--i-green: #15803d;
--i-green-l: #dcfce7;
--i-amber: #b45309;
--i-amber-l: #fef3c7;
--i-red: #c0392b;
--i-red-l: #fdecea;
--i-g100: #f1f5f9;
--i-g200: #e2e8f0;
--i-g500: #64748b;
--i-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--i-g900);
}
.ipr {
max-width: 1280px;
margin: 0 auto;
padding: 16px;
}
.ipr-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-bottom: 14px;
}
.ipr-head-t {
margin: 0;
font-size: 22px;
color: var(--i-navy);
}
.ipr-head-s {
max-width: 720px;
margin-top: 4px;
font-size: 13px;
color: var(--i-g500);
}
.ipr-head-a {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ipr-date {
display: flex;
flex-direction: column;
font-size: 11px;
color: var(--i-g500);
input,
select {
padding: 6px 8px;
border: 1px solid var(--i-g200);
border-radius: 6px;
font: inherit;
font-size: 13px;
}
}
.ipr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--i-g200);
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 13px;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: default;
}
}
.ipr-btn--primary {
border-color: var(--i-blue);
background: var(--i-blue);
color: #fff;
}
.ipr-btn--slim {
padding: 4px 10px;
font-size: 12px;
}
.ipr-alert {
display: flex;
gap: 8px;
margin-bottom: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.ipr-alert--error {
background: var(--i-red-l);
color: var(--i-red);
}
.ipr-alert--ok {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-alert--warn {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-card {
margin-bottom: 14px;
border: 1px solid var(--i-g200);
border-radius: 10px;
background: #fff;
overflow-x: auto;
}
.ipr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 9px 12px;
border-bottom: 1px solid var(--i-g100);
text-align: left;
vertical-align: top;
}
th {
background: var(--i-g100);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--i-g500);
}
tbody tr {
cursor: pointer;
}
tbody tr.is-selected {
background: var(--i-blue-l);
}
.num {
text-align: right;
white-space: nowrap;
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
cursor: default;
}
tfoot td {
background: var(--i-g100);
}
}
.muted {
color: var(--i-g500);
font-size: 12px;
}
.ipr-empty {
padding: 24px !important;
text-align: center !important;
color: var(--i-g500);
}
.ipr-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.ipr-badge--pending {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-badge--approved {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-badge--posted {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-badge--muted {
background: var(--i-g100);
color: var(--i-g500);
}
.ipr-fail {
margin-top: 4px;
font-size: 11px;
color: var(--i-red);
}
.ipr-detail-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
h3 {
margin: 0 0 4px;
font-size: 16px;
color: var(--i-navy);
}
}
.ipr-total {
display: flex;
flex-direction: column;
align-items: flex-end;
strong {
font-size: 22px;
color: var(--i-navy);
}
}
.ipr-due-body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.ipr-due-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
// Approved by the head office on behalf of a branch that did not open that day.
.ipr-behalf {
margin-left: 4px;
font-size: 11px;
font-weight: 600;
color: var(--i-amber);
}
// Preview: refresh / export / close, above the totals.
.ipr-preview-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
padding: 0 16px 10px;
}
.ipr-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
border-bottom: 1px solid var(--i-g200);
}
.ipr-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
margin-bottom: -1px;
font-size: 13px;
font-weight: 600;
color: var(--i-g500);
cursor: pointer;
background: transparent;
border: 1px solid transparent;
border-radius: 6px 6px 0 0;
&.is-active {
color: var(--i-navy);
background: #fff;
border-color: var(--i-g200);
border-bottom-color: #fff;
}
}
.ipr-alert--info {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-blocked {
display: inline-block;
max-width: 320px;
font-size: 11px;
line-height: 1.35;
color: var(--i-g500);
white-space: normal;
}
@@ -0,0 +1,398 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingDue,
InterestPostingReport,
InterestPostingReportDetail,
InterestPostingReportService,
InterestPreviewRow,
ReportScope
} from './interest-posting-report.service';
/**
* Savings interest posting reports.
*
* On a product's posting date the system generates, at noon, the list of what each savings account
* will be paid. It is reviewed and approved here; the approved amounts are posted when the last
* branch closes the day, and until then no branch can close. Regenerating replaces the report and
* needs approving again.
*/
@Component({
selector: 'mifosx-interest-posting-report',
templateUrl: './interest-posting-report.component.html',
styleUrls: ['./interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingReportComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
postingDate = '';
loading = false;
busy = false;
errorMessage = '';
notice = '';
reports: InterestPostingReport[] = [];
selected: InterestPostingReportDetail | null = null;
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: InterestPostingDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: ReportScope = 'own';
isHeadOffice = false;
myBranch = '';
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: ReportScope): void {
if (this.tab === tab) {
return;
}
this.tab = tab;
this.selected = null;
this.notice = '';
this.load();
}
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
private confirmOnBehalf(officeName: string, action: string): boolean {
if (this.tab !== 'others') {
return true;
}
return confirm(
`${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` +
'from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
if (stillThere) {
this.open(stillThere);
} else {
this.selected = null;
}
},
error: (err) => {
this.loading = false;
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: InterestPostingDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
}
});
}
open(report: InterestPostingReport): void {
this.errorMessage = '';
this.reportService.detail(report.id).subscribe({
next: (detail) => (this.selected = detail),
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
});
}
regenerate(report: InterestPostingReport): void {
if (
!confirm(
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report regenerated for ${report.productName}. Review and approve it.`;
this.selected = null;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
}
});
}
approve(report: InterestPostingReport): void {
if (
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
'It will be posted when the branch closes the day.'
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.approve(report.id).subscribe({
next: () => {
this.busy = false;
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
this.load();
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
}
});
}
canRegenerate(report: InterestPostingReport): boolean {
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
}
canApprove(report: InterestPostingReport): boolean {
return report.canAct && report.status === 'GENERATED';
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted',
SUPERSEDED: 'ipr-badge--muted'
}[status] ?? 'ipr-badge--muted'
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Preview: what each account has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: InterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso();
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the interest preview.');
}
});
}
closePreview(): void {
this.showPreview = false;
}
get previewTotal(): number {
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
}
/** Totals by branch and product, for the summary above the account list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
postingDate: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; postingDate: string; accounts: number; interest: number }
>();
this.previewRows.forEach((r) => {
const key = `${r.officeName}|${r.productName}`;
const g = map.get(key) ?? {
officeName: r.officeName,
productName: r.productName,
postingDate: r.postingDate,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By account',
title: 'Savings Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Account No',
'Customer',
'Balance',
'Rate %',
'Cycle Start',
'Posting Date',
'Interest So Far'
],
widths: [
18,
22,
20,
26,
16,
8,
12,
12,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
r.accountNo,
r.clientName,
Number(r.balance || 0),
Number(r.rate || 0),
r.cycleStart,
r.postingDate,
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Savings Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Posting Date',
'Accounts',
'Interest So Far'
],
widths: [
18,
22,
12,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
g.postingDate,
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`savings-interest-preview-${this.previewAsOf}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
private messageOf(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
);
}
}
@@ -0,0 +1,180 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type ReportScope = 'own' | 'others';
export interface InterestPostingReport {
id: number;
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
status: InterestPostingReportStatus;
totalInterest: number;
accountCount: number;
generationMode: 'AUTO' | 'MANUAL';
generatedAt: string;
generatedBy: string | null;
approvedAt: string | null;
approvedBy: string | null;
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
/** Why not, where canAct is false. */
blockedReason: string | null;
}
export interface InterestPostingReportLine {
savingsAccountId: number;
accountNo: string;
clientName: string;
interestAmount: number;
balanceBefore: number;
balanceAfter: number;
savingsTransactionId: number | null;
}
export interface InterestPostingReportDetail extends InterestPostingReport {
payableGlCode: string | null;
payableGlName: string | null;
paidGlCode: string | null;
paidGlName: string | null;
expenseGlCode: string | null;
expenseGlName: string | null;
lines: InterestPostingReportLine[];
}
export interface InterestPostingDue {
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
canAct: boolean;
blockedReason: string | null;
}
export interface InterestPostingSummaryRow {
id: number;
postingDate: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
accountCount: number;
totalInterest: number;
status: InterestPostingReportStatus;
approvedBy: string | null;
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
}
export interface InterestPreviewRow {
officeId: number;
officeName: string;
productId: number;
productName: string;
savingsAccountId: number;
accountNo: string;
clientName: string;
balance: number;
rate: number;
cycleStart: string;
postingDate: string;
asOf: string;
interest: number;
}
/**
* The savings interest posting reports, one per branch and product: generated at noon on a posting
* date, approved by the branch, posted when the branch closes the day.
*/
@Injectable({ providedIn: 'root' })
export class InterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/savingsinterestpostingreports';
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: ReportScope
): Observable<InterestPostingReport[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPostingReport[]>(this.base, { params });
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
dueWithoutReport(postingDate?: string, scope?: ReportScope): Observable<InterestPostingDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<InterestPostingDue[]>(`${this.base}/due-without-report`, { params });
}
/** The signed-in user's branch, and whether it is the head office. */
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean }>(`${this.base}/context`);
}
detail(reportId: number): Observable<InterestPostingReportDetail> {
return this.http.get<InterestPostingReportDetail>(`${this.base}/${reportId}`);
}
regenerate(
productId: number,
officeId: number,
postingDate: string
): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
this.base,
{ productId, officeId, postingDate },
{ params: new HttpParams().set('command', 'regenerate') }
);
}
approve(reportId: number): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
`${this.base}/${reportId}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
/** Interest each account has earned so far this cycle, as if posted today. View only. */
preview(officeId?: number): Observable<InterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<InterestPostingSummaryRow[]> {
let params = new HttpParams();
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (productId) params = params.set('productId', String(productId));
return this.http.get<InterestPostingSummaryRow[]>(`${this.base}/summary`, { params });
}
}
@@ -0,0 +1,172 @@
<!--
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="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Summary</h2>
<div class="ipr-head-s">
Savings interest across every branch: for each posting date, branch and product, how many customers were paid
and how much. Each branch approves and posts its own; this is the head office's view.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
From
<input type="date" [(ngModel)]="fromDate" (change)="load()" />
</label>
<label class="ipr-date">
To
<input type="date" [(ngModel)]="toDate" (change)="load()" />
</label>
<label class="ipr-date">
Product
<select [(ngModel)]="productId">
<option [ngValue]="null">All products</option>
@for (p of products; track p.id) {
<option [ngValue]="p.id">{{ p.name }}</option>
}
</select>
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="exportExcel()" [disabled]="loading || shown.length === 0">
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
</div>
</div>
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} branch report(s) in this period are not posted yet.</div>
</div>
}
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Branch totals</h3>
<div class="muted">{{ fromDate }} to {{ toDate }}</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ totalCustomers }} customer(s) · posted ₹{{ postedInterest | number: '1.2-2' }}</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Reports</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th class="num">Posted</th>
<th class="num">Not yet posted</th>
</tr>
</thead>
<tbody>
@for (b of byBranch; track b.officeName) {
<tr>
<td>
<strong>{{ b.officeName }}</strong>
</td>
<td class="num">{{ b.reports }}</td>
<td class="num">{{ b.customers }}</td>
<td class="num">₹{{ b.interest | number: '1.2-2' }}</td>
<td class="num">₹{{ b.posted | number: '1.2-2' }}</td>
<td class="num">₹{{ b.interest - b.posted | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report in this period.
}
</td>
</tr>
}
</tbody>
@if (byBranch.length > 0) {
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">
<strong>{{ shown.length }}</strong>
</td>
<td class="num">
<strong>{{ totalCustomers }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ postedInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest - postedInterest | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
}
</table>
</div>
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Posting date</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th>Status</th>
<th>Approved</th>
<th>Posted</th>
</tr>
</thead>
<tbody>
@for (r of shown; track r.id) {
<tr>
<td>{{ r.postingDate }}</td>
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="muted">{{ r.postedAt ? (r.postedAt | date: 'dd MMM, HH:mm') : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,244 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingReportService,
InterestPostingSummaryRow
} from '../interest-posting-report/interest-posting-report.service';
/**
* The head office's view of savings interest across branches: for each posting date, branch and
* product, how many customers were paid and how much, and whether it has been approved and posted.
* Nothing is approved here — each branch approves its own — so it is a report and an Excel export.
*/
@Component({
selector: 'mifosx-interest-posting-summary',
templateUrl: './interest-posting-summary.component.html',
styleUrls: ['../interest-posting-report/interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingSummaryComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
fromDate = '';
toDate = '';
productId: number | null = null;
loading = false;
errorMessage = '';
rows: InterestPostingSummaryRow[] = [];
ngOnInit(): void {
const today = this.businessDateIso();
this.toDate = today;
this.fromDate = `${today.slice(0, 7)}-01`;
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.summary(this.fromDate || undefined, this.toDate || undefined).subscribe({
next: (rows) => {
this.rows = rows ?? [];
this.loading = false;
},
error: (err) => {
this.loading = false;
this.rows = [];
this.errorMessage =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.message ??
'Could not load the interest summary.';
}
});
}
/** Products present in the loaded rows, for the filter. */
get products(): { id: number; name: string }[] {
const seen = new Map<number, string>();
this.rows.forEach((r) => seen.set(r.productId, r.productName));
return [...seen.entries()].map(
([
id,
name
]) => ({ id, name })
);
}
get shown(): InterestPostingSummaryRow[] {
return this.productId ? this.rows.filter((r) => r.productId === this.productId) : this.rows;
}
get totalCustomers(): number {
return this.shown.reduce((sum, r) => sum + (r.accountCount || 0), 0);
}
get totalInterest(): number {
return this.shown.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get postedInterest(): number {
return this.shown.filter((r) => r.status === 'POSTED').reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get pendingCount(): number {
return this.shown.filter((r) => r.status !== 'POSTED').length;
}
/** Branch totals, for the second table and the export. */
get byBranch(): { officeName: string; customers: number; interest: number; posted: number; reports: number }[] {
const map = new Map<
string,
{ officeName: string; customers: number; interest: number; posted: number; reports: number }
>();
this.shown.forEach((r) => {
const key = r.officeName || '—';
const row = map.get(key) ?? { officeName: key, customers: 0, interest: 0, posted: 0, reports: 0 };
row.customers += r.accountCount || 0;
row.interest += Number(r.totalInterest || 0);
row.posted += r.status === 'POSTED' ? Number(r.totalInterest || 0) : 0;
row.reports += 1;
map.set(key, row);
});
return [...map.values()].sort((a, b) => a.officeName.localeCompare(b.officeName));
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted'
}[status] ?? 'ipr-badge--muted'
);
}
exportExcel(): void {
const period = `Posting dates ${this.fromDate || '…'} to ${this.toDate || '…'}`;
const product = this.productId ? this.products.find((p) => p.id === this.productId)?.name : 'All products';
exportWorkbook(
[
{
sheetName: 'By report',
title: 'Savings Interest — Branch-wise Posting',
subtitle: `${period} · ${product}`,
headers: [
'Posting Date',
'Branch',
'Product',
'Customers',
'Interest',
'Status',
'Approved By',
'Approved At',
'Posted At'
],
widths: [
14,
22,
24,
12,
16,
12,
20,
20,
20
],
rows: [
...this.shown.map((r) => [
r.postingDate,
r.officeName,
r.productName,
r.accountCount,
Number(r.totalInterest || 0),
r.status,
(r.approvedBy || '') + (r.approvedOnBehalf ? ' (HO)' : ''),
r.approvedAt || '',
r.postedAt || ''
]),
[
'TOTAL',
'',
'',
this.totalCustomers,
this.totalInterest,
'',
'',
'',
''
]
]
},
{
sheetName: 'By branch',
title: 'Savings Interest — Branch Totals',
subtitle: `${period} · ${product}`,
headers: [
'Branch',
'Reports',
'Customers',
'Interest',
'Posted',
'Not Yet Posted'
],
widths: [
22,
10,
12,
16,
16,
16
],
rows: [
...this.byBranch.map((b) => [
b.officeName,
b.reports,
b.customers,
b.interest,
b.posted,
b.interest - b.posted
]),
[
'TOTAL',
this.shown.length,
this.totalCustomers,
this.totalInterest,
this.postedInterest,
this.totalInterest - this.postedInterest
]
]
}
],
`savings-interest-branch-summary-${this.fromDate}-to-${this.toDate}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
}
@@ -129,11 +129,30 @@
<!-- ═══ GL accounts ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<div class="rrc-gl-head">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<!--
The two returns share a base but maintain their requirement in different assets, so an
administrator setting one up is looking at a different set of heads than one setting up
the other. Showing all of them at once buries the few that matter for the task in hand.
-->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-gl-head__filter">
<mat-label>Configuring</mat-label>
<mat-select [(ngModel)]="formFilter" (selectionChange)="onFormFilterChange()">
@for (f of formFilters; track f.value) {
<mat-option [value]="f.value">{{ f.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<p class="rrc-card__hint">
Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each
one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of
its children, so mapping both would count those children twice.
@if (formFilter !== 'ALL') {
Heads that build or net the DTL base are shown for either return, since both charge their rate on that same
base.
}
</p>
@if (unmappedCount) {
@@ -385,6 +385,20 @@ $red: #c5221f;
margin: 0 !important;
}
// Title and the return filter on one line: the filter says what the list below is showing, so it
// belongs beside the heading rather than buried among the roles.
.rrc-gl-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
&__filter {
width: 260px;
}
}
.rrc-remove {
min-width: 0 !important;
padding: 0 8px !important;
@@ -64,6 +64,83 @@ export class RbiReserveConfigComponent implements OnInit {
/** The roles grouped by the return they feed, in backend order. */
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form 9, or both together.
*
* The two returns share a base but maintain their requirement in different assets, so an
* administrator setting one up is looking at a different set of heads than one setting up the
* other. Showing all twenty-six at once buries the four that matter for the task in hand.
*/
formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
this.rebuildGroups();
}
/**
* The roles this screen shows: those in its own group, narrowed to the return selected above.
*
* The group scope is widened for Form IX. Its heads were seeded into their own group so they sit
* apart on the backend, and a screen fixed to 'RESERVE' would filter every one of them out before
* the return filter ever ran - leaving the Form IX option selectable and its list empty.
*/
private rolesForScreen(): any[] {
const wanted = this.formFilter === 'FORM_IX' ? 'FORM9' : this.group;
const scoped = wanted ? this.allRoles.filter((r: any) => r.group === wanted) : this.allRoles;
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
}
readonly formFilters = [
{ value: 'ALL', label: 'All returns' },
{ value: 'FORM_1', label: 'Form 1 — CRR (fortnightly)' },
{ value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* Decided from how the return consumes the head rather than from its name: MAINTAINED is met in
* cash and balances and so is Form 1's, SLR_ASSET is met in securities and gold and so is Form
* 9's, and a head that builds or nets the DTL base belongs to both because both charge their rate
* on that same base. A memo head is stated on either return and so shows on both.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
return true;
}
// Form IX's heads are placed by the group they were seeded into, not by usage. Its usages -
// LIABILITY and ASSET - read the same as Form 1's but mean something different: on Form IX they
// say which SIDE OF THE BALANCE SHEET a head sits on, not how it enters a ratio. Sixty heads
// showing up under Form 1's filter would bury the eight that belong there.
const group = role.group || role.roleGroup;
if (group === 'FORM9') {
return this.formFilter === 'FORM_IX';
}
if (this.formFilter === 'FORM_IX') {
return false;
}
// The built-in SLR investments role predates the SLR_ASSET usage and still carries MAINTAINED,
// so it is placed by name; every other head is placed by how the return consumes it.
if (role.configKey === 'SLR_INVESTMENTS') {
return this.formFilter === 'FORM_9';
}
const usage = role.usage || role.roleUsage;
if (usage === 'MAINTAINED') {
return this.formFilter === 'FORM_1';
}
if (usage === 'SLR_ASSET') {
return this.formFilter === 'FORM_9';
}
// LIABILITY, ASSET_NET and MEMO all appear on both returns.
return true;
}
/** Which group's roles are on screen. Unused when the screen is scoped to one group. */
activeGroup = '';
rates: any[] = [];
@@ -103,6 +180,9 @@ export class RbiReserveConfigComponent implements OnInit {
{ 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)' },
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
// on. LIABILITY above serves its liability side, and this its asset side.
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
{ value: 'MEMO', label: 'Memo — shown on the return but totals nothing' }
];
@@ -140,10 +220,11 @@ export class RbiReserveConfigComponent implements OnInit {
}
private applyConfig(config: any): void {
const allRoles = config.glRoles || [];
// Kept so the form filter can re-slice without a round trip.
this.allRoles = config.glRoles || [];
// Scoped to this screen's return before anything else, so every count, warning and Save on the
// page speaks about the roles the user can actually see.
this.glRoles = this.group ? allRoles.filter((r: any) => r.group === this.group) : allRoles;
this.glRoles = this.rolesForScreen();
this.rates = config.rates || [];
this.selection = {};
for (const role of this.glRoles) {
@@ -61,12 +61,7 @@
}
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
[disabled]="savingGl || !isGlDirty(role)"
(click)="saveGl(role)"
>
<button mat-raised-button color="primary" [disabled]="savingGl || !isGlDirty(role)" (click)="saveGl(role)">
{{ savingGl ? 'Saving…' : 'Save' }}
</button>
</div>
@@ -78,8 +73,8 @@
<div class="rrc-card">
<h3 class="rrc-card__title">Financial years</h3>
<p class="rrc-card__hint">
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting
that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep
quoting that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
</p>
<div class="rrc-tabs" role="tablist">
@@ -37,7 +37,10 @@ interface FieldDef {
@Component({
selector: 'mifosx-tds-config',
templateUrl: './tds-config.component.html',
styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'],
styleUrls: [
'../rbi-reserve-config/rbi-reserve-config.component.scss',
'./tds-config.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -67,7 +70,12 @@ export class TdsConfigComponent implements OnInit {
readonly rateFields: FieldDef[] = [
{ key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' },
{ key: 'thresholdSenior', label: 'Threshold – senior citizen (₹)', kind: 'amount', hint: '60 at any time in the year' },
{
key: 'thresholdSenior',
label: 'Threshold – senior citizen (₹)',
kind: 'amount',
hint: '60 at any time in the year'
},
{ key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' },
{ key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' },
{ key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' },
@@ -96,7 +104,12 @@ export class TdsConfigComponent implements OnInit {
{ value: 'QUARTERLY', label: 'Quarterly' }
]
},
{ key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' }
{
key: 'declarationStatementDueDay',
label: 'Statement due on day',
kind: 'int',
hint: 'Of the following month / quarter'
}
];
readonly statuteFields: FieldDef[] = [
@@ -226,22 +226,18 @@ export class CurrentProductSettingsStepComponent implements OnInit {
const percent = form.get('odExcessLimitPercent');
const penal = form.get('odPenalRate');
percent.setValidators(
required
? [
required ? [
Validators.required,
Validators.min(0.01),
Validators.max(25)
]
: []
] : []
);
penal.setValidators(
required
? [
required ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: []
] : []
);
percent.updateValueAndValidity({ emitEvent: false });
penal.updateValueAndValidity({ emitEvent: false });
@@ -23,7 +23,9 @@
@if (!isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span>
<span
>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span
>
</div>
}
@@ -84,8 +84,12 @@ export class CurrentProductTermsStepComponent implements OnInit {
this.CurrentProductTermsForm.patchValue({
interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting,
interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365,
nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '',
minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '',
nominalAnnualInterestRateOverdraft: isEditing
? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft
: '',
minOverdraftForInterestCalculation: isEditing
? this.CurrentProductsTemplate.minOverdraftForInterestCalculation
: '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true
});
@@ -98,13 +102,11 @@ export class CurrentProductTermsStepComponent implements OnInit {
private applyOverdraftValidators(allowed: boolean) {
const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft');
rate.setValidators(
allowed
? [
allowed ? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: [Validators.min(0)]
] : [Validators.min(0)]
);
rate.updateValueAndValidity({ emitEvent: false });
}
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Fixed Deposit Interest Paid';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -128,6 +132,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -158,6 +163,20 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
return '';
}
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.fixedDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private patchAccountingValues(mappings: any) {
this.fixedDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: this.extractAccountId(
@@ -200,7 +219,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -266,10 +296,26 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.fixedDepositProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -416,6 +462,13 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Fixed Deposit Interest Paid',
inputLabel: 'Fixed deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -456,6 +509,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
savingsInterestPaidAccountId,
...accounting
} = this.fixedDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Recurring Deposit Interest Paid';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -122,6 +126,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -136,6 +141,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
});
}
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.recurringDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private patchAccountingValues(mappings: any) {
this.recurringDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
@@ -158,7 +177,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -224,10 +254,26 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.recurringDepositProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -360,11 +406,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
label: 'Interest On Recurring Deposits',
inputLabel: 'Interest on recurring deposits',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Recurring Deposit Interest Paid',
inputLabel: 'Recurring deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -405,6 +458,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
savingsInterestPaidAccountId,
...accounting
} = this.recurringDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -88,6 +88,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
liabilityAccountOptions: GLAccount[] = [];
expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = [];
/** Savings Interest Paid sits under Expenses and offers expense GLs; it nets to nil at each posting. */
paidAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = [];
paymentTypeOptions: any[] = [];
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
@@ -114,6 +116,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.paidAccountOptions = this.expenseAccountOptions;
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs();
@@ -201,26 +204,30 @@ export class SavingProductAccountingStepComponent implements OnInit {
// Backend serializes as 'otherGlMappingsMap'; fall back to 'otherGlMappings' for compatibility
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
this.setOtherGlMappings(
'liabilitiesOtherGlMappings',
// Payable is a liability row, saved under liabilities; Paid an expense row, saved under
// expenses. Products saved before either move carry them in the other bucket, so both buckets
// are read and the named entries are stripped out of either one's "Other" rows.
const fromLiabilities = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.liabilities || [],
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
'savingsInterestPayableAccountId',
this.liabilityAccountOptions
),
this.liabilityAccountOptions
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.paidAccountOptions
);
this.setOtherGlMappings(
'expensesOtherGlMappings',
this.extractNamedGlMapping(
glMappings?.expenses || [],
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
(glMappings?.expenses || []).filter(
(mapping: any) => mapping?.accountName !== SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME
),
this.expenseAccountOptions
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.paidAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -336,11 +343,13 @@ export class SavingProductAccountingStepComponent implements OnInit {
),
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
// The backend finds Savings Interest Paid by name in any bucket; it sits under expenses
// because that is where the screen shows it.
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
this.paidAccountOptions
),
...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
],
@@ -378,14 +387,16 @@ export class SavingProductAccountingStepComponent implements OnInit {
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
// The interest expense: every interest posting debits it for the product's total.
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
// rather than as named accounting params, which would be silently dropped. Optional by design.
addControl('savingsInterestPayableAccountId', false);
addControl('savingsInterestPaidAccountId', false);
// rather than as named accounting params, which would be silently dropped.
// Required: interest posting refuses a product without them rather than skipping their legs.
addControl('savingsInterestPayableAccountId', true);
addControl('savingsInterestPaidAccountId', true);
// A real accounting param, unlike the two above, so it rides out as itself rather than
// inside otherGlMappings. Optional: a product saved without one still closes.
addControl('roundingAdjustmentAccountId', false);
@@ -522,11 +533,14 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
// Credited with the product's total interest at posting and drawn straight back down,
// so it closes each posting at nil.
key: 'savingsInterestPayableAccountId',
label: 'Savings Interest Payable',
inputLabel: 'Savings interest payable',
controlName: 'savingsInterestPayableAccountId',
options: this.liabilityAccountOptions
options: this.liabilityAccountOptions,
required: true
},
{
key: 'interestPayableAccountId',
@@ -555,6 +569,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
pillClass: 'amber',
rows: [
{
// The interest cost: debited for the product's total at every posting.
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
@@ -563,11 +578,13 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
// A pass-through between the payable and the members that closes each posting at nil.
key: 'savingsInterestPaidAccountId',
label: 'Savings Interest Paid',
inputLabel: 'Savings interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
options: this.paidAccountOptions,
required: true
},
{
// Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the
@@ -0,0 +1,261 @@
<!--
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="f9-container">
<!--
═══ Date prompt ═══
Asked before anything is fetched: the return reports a month end, and which month it covers
follows from this one field.
-->
@if (awaitingDate) {
<div class="f9-prompt">
<h2 class="f9-prompt__title">Form IX — Return on Assets and Liabilities</h2>
<p class="f9-prompt__sub">
Return code R408, filed monthly. Every figure is a closing balance as at the month end, in rupees thousands.
</p>
<div class="f9-prompt__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f9-prompt__date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="reportingDate" />
</mat-form-field>
<button mat-raised-button color="primary" (click)="generate()">Generate Form IX</button>
</div>
@if (chosenMonth) {
<p class="f9-prompt__hint">
Month: <strong>{{ chosenMonth }}</strong>
</p>
}
<div class="f9-prompt__quick">
<span>Jump to:</span>
<button mat-stroked-button (click)="setToMonthEnd()">Month end</button>
</div>
@if (errorMsg) {
<div class="f9-alert f9-alert--error">{{ errorMsg }}</div>
}
</div>
} @else if (loading) {
<div class="f9-loading">Computing from the ledger…</div>
} @else if (errorMsg) {
<div class="f9-alert f9-alert--error">{{ errorMsg }}</div>
} @else if (data) {
<div class="f9-bar f9-noprint">
<span class="f9-bar__date">
As at <strong>{{ data.reportingDate }}</strong> · {{ data.unit }}
</span>
<button mat-stroked-button (click)="changeDate()">Change date</button>
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
</button>
<button mat-raised-button color="primary" [disabled]="generating" (click)="exportWorkbook()">
<i class="fas fa-file-excel"></i>
{{ generating ? 'Generating…' : 'RBI workbook' }}
</button>
</div>
<div class="f9-sheet" id="f9-printable">
<div class="f9-sheet__head">
<div class="f9-sheet__title">{{ data.formName }}</div>
<div class="f9-sheet__ref">{{ data.statutoryReference }} · Return code {{ data.returnCode }}</div>
<div class="f9-sheet__unit">As at {{ data.reportingDate }} · {{ data.unit }}</div>
</div>
<!--
═══ Reconciliation ═══
At the top, not the bottom. A return whose two sides disagree has something unmapped or
mapped twice, and a reviewer who learns that after reading the statement has already
believed the statement.
-->
<div class="f9-recon" [class.f9-recon--off]="!data.reconciliation.balanced">
<div class="f9-recon__cell">
<span class="f9-recon__label">Total Liabilities</span>
<span class="f9-recon__value">{{ amount(data.reconciliation.totalLiabilities) }}</span>
</div>
<div class="f9-recon__cell">
<span class="f9-recon__label">Total Assets</span>
<span class="f9-recon__value">{{ amount(data.reconciliation.totalAssets) }}</span>
</div>
<div class="f9-recon__cell">
<span class="f9-recon__label">Difference</span>
<span class="f9-recon__value" [class.f9-neg]="!data.reconciliation.balanced">
{{ amount(data.reconciliation.difference) }}
</span>
<span class="f9-recon__sub">
{{
data.reconciliation.balanced
? 'The two sides agree'
: rupees(data.reconciliation.differenceInRupees) + ' in rupees'
}}
</span>
</div>
</div>
@if (data.caveats.length) {
<div class="f9-alert f9-alert--warn">
<strong>Check before filing.</strong>
<ul class="f9-caveats">
@for (caveat of data.caveats; track caveat) {
<li>{{ caveat }}</li>
}
</ul>
</div>
}
<!-- ═══ A. Liabilities ═══ -->
<table class="f9-table">
<thead>
<tr>
<th class="f9-table__particulars">A. Liabilities in India</th>
<th class="f9-table__num">Rs. in Thousands</th>
</tr>
</thead>
<tbody>
@for (line of data.liabilityLines; track line.configKey) {
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
<td class="f9-table__particulars">
{{ line.label }}
@if (!line.configured) {
<span class="f9-badge">Not mapped</span>
}
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
mapped under Balance of Profit but reported on the asset side. -->
@if (line.note) {
<span class="f9-line__note">{{ line.note }}</span>
}
</td>
<td class="f9-table__num">{{ amount(line.amount) }}</td>
</tr>
<!-- The accounts behind the head. On a "specify" head these rows ARE the sheet the
RBI workbook asks for — one row per account, under its own name. -->
@for (account of line.accounts; track $index) {
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
<td>
<span class="f9-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (account.excluded) {
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
}
@if (isPartial(account)) {
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
}
</td>
<td class="f9-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
<tr class="f9-total">
<td>Total Demand and Time Liabilities (4 + 5 + 6)</td>
<td class="f9-table__num">{{ amount(total('totalDemandAndTime')) }}</td>
</tr>
<tr class="f9-total f9-total--grand">
<td>TOTAL LIABILITIES</td>
<td class="f9-table__num">{{ amount(total('totalLiabilities')) }}</td>
</tr>
</tbody>
</table>
<!-- ═══ B. Assets ═══ -->
<table class="f9-table">
<thead>
<tr>
<th class="f9-table__particulars">B. Assets in India</th>
<th class="f9-table__num">Rs. in Thousands</th>
</tr>
</thead>
<tbody>
@for (line of data.assetLines; track line.configKey) {
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
<td class="f9-table__particulars">
{{ line.label }}
@if (!line.configured) {
<span class="f9-badge">Not mapped</span>
}
<!-- Why a line reads as it does, where the figure alone would mislead — a loss
mapped under Balance of Profit but reported on the asset side. -->
@if (line.note) {
<span class="f9-line__note">{{ line.note }}</span>
}
</td>
<td class="f9-table__num">{{ amount(line.amount) }}</td>
</tr>
<!-- A head whose sub-rows carry the opposite sign says so on its own line. Three
negative balances under a positive total otherwise read as an error rather than
as the ledger side the figure was carried from. -->
@if (line.accountsMirrorHead && line.accounts.length) {
<tr class="f9-subline f9-subline--mirror">
<td colspan="2">
As the ledger holds them — these balances carry the opposite sign and add to the figure above
</td>
</tr>
}
@for (account of line.accounts; track $index) {
<tr class="f9-subline" [class.f9-subline--excluded]="account.excluded">
<td>
<span class="f9-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (account.excluded) {
<span class="f9-subline__excluded">excluded — {{ account.excluded }}</span>
}
@if (isPartial(account)) {
<span class="f9-subline__portion">{{ account.weightPercentage }}% share</span>
}
</td>
<td class="f9-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
<tr class="f9-total f9-total--grand">
<td>TOTAL ASSETS</td>
<td class="f9-table__num">{{ amount(total('totalAssets')) }}</td>
</tr>
</tbody>
</table>
<!-- ═══ Stated splits and explanations ═══ -->
@if (data.memoLines.length) {
<table class="f9-table">
<thead>
<tr>
<th class="f9-table__particulars">Stated splits and explanations — entering no total</th>
<th class="f9-table__num">Rs. in Thousands</th>
</tr>
</thead>
<tbody>
@for (line of data.memoLines; track line.configKey) {
<tr class="f9-line" [class.f9-line--unconfigured]="!line.configured">
<td class="f9-table__particulars">
{{ line.label }}
@if (!line.configured) {
<span class="f9-badge">Not mapped</span>
}
</td>
<td class="f9-table__num">{{ amount(line.amount) }}</td>
</tr>
}
<tr class="f9-line">
<td class="f9-table__particulars">Percentage of overdues to total loans and advances</td>
<td class="f9-table__num">{{ percent(total('overduePercentage')) }}</td>
</tr>
<tr class="f9-line">
<td class="f9-table__particulars">Priority sector advances as a percentage of total loans</td>
<td class="f9-table__num">{{ percent(total('prioritySectorPercentage')) }}</td>
</tr>
</tbody>
</table>
}
<div class="f9-sign">
<div class="f9-sign__line">AUTHORISED SIGNATORY</div>
</div>
</div>
}
</div>
@@ -0,0 +1,395 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1353a0;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
$green: #137333;
$amber: #b06000;
$red: #c5221f;
.f9-container {
max-width: 1180px;
margin: 0 auto;
padding: 20px 16px;
}
// ── Date prompt ──
// Centred and roomy rather than a strip at the top: nothing is on screen yet, so this is the
// screen, and a filer choosing the month the return covers should not be hunting for the field.
.f9-prompt {
max-width: 640px;
margin: 48px auto;
padding: 28px 32px;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
text-align: center;
box-shadow: 0 1px 3px rgb(60 64 67 / 12%);
&__title {
margin: 0 0 8px;
font-size: 19px;
font-weight: 600;
color: $gray-900;
}
&__sub {
margin: 0 0 22px;
font-size: 13px;
line-height: 1.6;
color: $gray-500;
}
&__row {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
&__date {
width: 200px;
}
&__hint {
margin: 16px 0 0;
font-size: 12.5px;
color: $primary;
}
&__quick {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
margin-top: 18px;
font-size: 12px;
color: $gray-500;
}
}
.f9-loading {
padding: 48px;
text-align: center;
color: $gray-500;
font-size: 13px;
}
.f9-alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 12.5px;
margin-bottom: 16px;
line-height: 1.6;
&--error {
background: #fce8e6;
color: $red;
}
&--warn {
background: #fff8e1;
color: $amber;
border: 1px solid #e0c56b;
}
}
.f9-caveats {
margin: 8px 0 0 18px;
padding: 0;
}
// ── Toolbar ──
.f9-bar {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 18px;
&__date {
flex: 1;
font-size: 13px;
color: $primary;
font-weight: 500;
}
}
// ── The form itself ──
.f9-sheet {
background: #fff;
border: 1px solid $gray-900;
padding: 20px;
&__head {
text-align: center;
border-bottom: 2px solid $gray-900;
padding-bottom: 12px;
margin-bottom: 16px;
}
&__title {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
color: $gray-900;
}
&__ref {
font-size: 11.5px;
color: $gray-500;
margin-top: 4px;
}
&__unit {
font-size: 11.5px;
color: $primary;
font-weight: 600;
margin-top: 4px;
}
}
// ── Reconciliation ──
// Green when the two sides agree, red when they do not. The colour is the point: a return that
// does not balance has something unmapped or mapped twice, and that has to be visible before
// anyone reads a figure off the statement below.
.f9-recon {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: $gray-200;
border: 1px solid $green;
border-radius: 8px;
overflow: hidden;
margin-bottom: 18px;
&--off {
border-color: $red;
}
&__cell {
background: #fff;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $gray-500;
font-weight: 600;
}
&__value {
font-size: 20px;
font-weight: 600;
color: $gray-900;
font-variant-numeric: tabular-nums;
}
&__sub {
font-size: 11px;
color: $gray-500;
}
}
.f9-neg {
color: $red;
}
// ── Tables ──
.f9-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
margin-bottom: 20px;
th,
td {
border: 1px solid $gray-200;
padding: 6px 10px;
vertical-align: top;
}
th {
background: $gray-50;
font-weight: 700;
font-size: 11.5px;
color: $gray-900;
border-color: $gray-900;
text-align: left;
}
&__particulars {
width: 72%;
}
&__num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
width: 1%;
min-width: 140px;
}
// Qualified with the element so a numeric heading is not dragged back to the left by the bare
// `th` rule above, which matches at the same specificity and would win on source order alone.
th#{&}__num {
text-align: right;
}
}
.f9-line {
&--unconfigured {
background: #fffdf8;
}
// Set on its own line rather than inline: it explains the figure beside it, and read as a
// trailing clause on the label it looks like part of the head's name.
&__note {
display: block;
margin-top: 3px;
font-size: 10.5px;
font-style: italic;
color: $amber;
}
}
.f9-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $amber;
background: #fff3cd;
border: 1px solid #e0c56b;
border-radius: 3px;
}
// An account behind a head. These rows are the "specify" sheets of the RBI workbook — one row
// per account, under its own name — so they are indented under their head rather than hidden.
.f9-subline {
td {
padding-top: 4px;
padding-bottom: 4px;
font-size: 11.5px;
color: $gray-500;
border-bottom: none;
}
td:first-child {
padding-left: 28px;
}
&--excluded td {
background: #fffdf5;
}
// The line that warns the rows beneath it read the other way round. Set apart from the balances
// themselves, since it explains them rather than being one of them.
&--mirror td {
padding-left: 28px;
font-style: italic;
font-size: 10.5px;
color: $gray-500;
background: #fbfcfe;
}
&__code {
font-family: 'Roboto Mono', monospace;
font-size: 10.5px;
background: $gray-50;
border: 1px solid $border;
border-radius: 4px;
padding: 1px 5px;
margin-right: 6px;
}
&__portion {
margin-left: 8px;
padding: 0 5px;
font-size: 10px;
font-weight: 600;
color: $amber;
background: #fff3cd;
border-radius: 3px;
}
&__excluded {
margin-left: 8px;
font-size: 10px;
font-weight: 600;
color: $amber;
}
}
.f9-total td {
font-weight: 700;
background: #fafafa;
color: $gray-900;
}
.f9-total--grand td {
background: #e8f0fe;
font-size: 13px;
}
.f9-sign {
margin-top: 56px;
text-align: right;
&__line {
display: inline-block;
border-top: 1px solid $gray-900;
padding-top: 6px;
font-size: 12px;
min-width: 230px;
text-align: center;
}
}
@media print {
.f9-noprint {
display: none !important;
}
.f9-container {
max-width: none;
padding: 0;
}
.f9-table thead {
display: table-header-group;
}
.f9-table tr {
break-inside: avoid;
}
// Backgrounds carry meaning here — an unmapped head is tinted, a reconciliation that fails is
// red — so they must survive the print engine's default of dropping them to save ink.
.f9-sheet,
.f9-table,
.f9-table th,
.f9-table td,
.f9-recon,
.f9-badge,
.f9-alert {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -0,0 +1,492 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { catchError, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiCircularsService } from '../rbi-circulars.service';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
/** One account behind a head — a row of the form's "specify" sheets. */
export interface Form9Account {
glAccountId: number;
glCode: string;
name: string;
/** In thousands, as the form prints it. */
balance: number | null;
/** The same balance in rupees, which is what reconciles against the ledger. */
balanceInRupees?: number | null;
weightPercentage?: number | null;
liabilityClass?: string | null;
excluded?: 'disabled' | 'header';
}
/** One head of the return. */
export interface Form9Line {
configKey: string;
label: string;
usage: 'LIABILITY' | 'ASSET' | 'MEMO';
configured: boolean;
amount: number | null;
amountInRupees: number | null;
accounts: Form9Account[];
/** Why a line reads as it does, where that is not obvious — a loss moved to the asset side. */
note?: string;
/**
* Whether the accounts below carry the opposite sign to the head.
*
* True on a figure the return carries across sides: a loss shown as a positive at item 12 is made
* up of balances that are negative where the ledger holds them. Without saying so, three negative
* rows under a positive head read as an error.
*/
accountsMirrorHead?: boolean;
}
export interface Form9Reconciliation {
totalLiabilities: number;
totalAssets: number;
difference: number;
differenceInRupees: number;
balanced: boolean;
}
export interface Form9Return {
formCode: string;
formName: string;
returnCode: string;
statutoryReference: string;
frequency: string;
unit: string;
reportingDate: string;
monthEnd: string;
monthComplete: boolean;
note: string;
liabilityLines: Form9Line[];
assetLines: Form9Line[];
memoLines: Form9Line[];
totals: Record<string, number | null>;
reconciliation: Form9Reconciliation;
caveats: string[];
computable: boolean;
}
/**
* Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408.
*
* A different return from Form 9 (SLR) despite the name, and it does not share that screen's
* machinery: this one reports the whole balance sheet rather than one ratio, in rupees thousands
* rather than rupees, and its two sides have to be shown against each other.
*
* The reconciliation is on the face of the screen whether or not it holds. The filed return this
* was built from balanced its sides by subtracting the difference from the side that was already
* short and printed "DIFF ADJUSTED" — so the one thing a reviewer needed to know was the one thing
* the report would not say.
*/
@Component({
selector: 'mifosx-form9-return',
templateUrl: './form9-return.component.html',
styleUrls: ['./form9-return.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Form9ReturnComponent {
private rbiService = inject(RbiCircularsService);
data: Form9Return | null = null;
loading = false;
errorMsg = '';
generating = false;
/**
* Nothing is fetched until a date is chosen. Which month the return covers follows from that one
* field, and a return that opened on today's month would be a month still running — reported as
* though it had closed.
*/
awaitingDate = true;
/** Today, as a date input formats it — also the latest date the return may be run for. */
readonly today = new Date().toISOString().slice(0, 10);
reportingDate = this.today;
generate(): void {
if (!this.reportingDate) {
this.errorMsg = 'Choose a reporting date first.';
return;
}
// A balance is summed as everything up to a date, so a future one does not fail and does not
// come back empty — it returns today's figures under a heading that has not happened.
if (this.reportingDate > this.today) {
this.errorMsg = `The reporting date (${this.reportingDate}) has not happened yet — today is ${this.today}.`;
return;
}
this.awaitingDate = false;
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.rbiService
.getForm9Return(this.reportingDate)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute the return from the ledger.';
return of(null);
})
)
.subscribe((res: Form9Return | null) => {
this.loading = false;
this.data = res;
});
}
/** Back to the date prompt, so a filer can run a different month. */
changeDate(): void {
this.awaitingDate = true;
this.data = null;
this.errorMsg = '';
}
/** The month the chosen date falls in, shown beside the picker. */
get chosenMonth(): string {
if (!this.reportingDate) {
return '';
}
const d = new Date(this.reportingDate + 'T00:00:00');
return d.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
}
/** Sets the date to the end of the month it currently falls in, capped at today. */
setToMonthEnd(): void {
const d = this.reportingDate ? new Date(this.reportingDate + 'T00:00:00') : new Date();
const end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
const iso = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`;
// Capped: the current month's end has not arrived, and the return would be refused.
this.reportingDate = iso > this.today ? this.today : iso;
}
amount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return value.toLocaleString('en-IN', { maximumFractionDigits: 0 });
}
/** Rupee figures keep their paise — they are what reconciles against the ledger. */
rupees(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
percent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return `${value.toFixed(2)}%`;
}
/** A head taking only part of an account — the figure below is not that account's balance. */
isPartial(account: Form9Account): boolean {
const weight = account.weightPercentage;
return weight !== null && weight !== undefined && weight < 100;
}
/** One of the form's stated totals. */
total(key: string): number | null {
return this.data?.totals?.[key] ?? null;
}
printReturn(): void {
window.print();
}
/**
* The return as a workbook, laid out as the RBI template is: General Information, then Liabilities
* and Assets, then a sheet per "specify" list, then Signatory Information.
*
* A workbook that reorganised the form would have to be read back against the screen line by line
* before anyone could trust it.
*/
async exportWorkbook(): Promise<void> {
if (!this.data) {
return;
}
this.generating = true;
try {
const wb = newWorkbook();
this.addGeneralInformation(wb);
this.addSideSheet(wb, 'Liabilities', 'A. Liabilities in India', this.data.liabilityLines);
this.addSideSheet(wb, 'Assets', 'B. Assets in India', this.data.assetLines);
this.addSpecifySheets(wb);
this.addMemoSheet(wb);
this.addSignatorySheet(wb);
await downloadWorkbook(wb, `RBI_Form_IX_${this.data.reportingDate}`);
} finally {
this.generating = false;
}
}
private addGeneralInformation(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('General Information');
sheet.columns = [
{ width: 42 },
{ width: 46 }];
addBanner(sheet, 'General Information', 'B');
const start = sheet.rowCount + 1;
this.keyValue(sheet, 'Return Name', this.data!.formName);
this.keyValue(sheet, 'Return Code', this.data!.returnCode);
this.keyValue(sheet, 'Period End Date', this.data!.reportingDate);
this.keyValue(sheet, 'Reporting Frequency', this.data!.frequency);
this.keyValue(sheet, 'Unit', this.data!.unit);
// Whether the return balances belongs here, at the top, not buried under the figures: a
// reviewer who reads it after the statement has already believed the statement.
this.keyValue(
sheet,
'Validation Status',
this.data!.reconciliation.balanced
? 'Balanced'
: `NOT BALANCED — liabilities exceed assets by ${this.amount(this.data!.reconciliation.difference)} thousand`
);
boxAllCells(sheet, start, sheet.rowCount, 2);
if (this.data!.caveats.length) {
sheet.addRow([]);
const head = sheet.addRow(['Check before filing:']);
head.font = { bold: true, color: { argb: 'FFB06000' } };
for (const caveat of this.data!.caveats) {
const row = sheet.addRow([caveat]);
sheet.mergeCells(row.number, 1, row.number, 2);
row.alignment = { wrapText: true };
row.font = { size: 9, color: { argb: 'FFB06000' } };
}
}
}
private addSideSheet(wb: ExcelJS.Workbook, name: string, title: string, lines: Form9Line[]): void {
const sheet = wb.addWorksheet(name);
sheet.columns = [
{ width: 64 },
{ width: 20 },
{ width: 22 }];
addBanner(sheet, title, 'C');
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'Particulars',
'Rs. in Thousands',
'Rs. (as in ledger)'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of lines) {
const row = sheet.addRow([
line.label,
line.configured ? line.amount : 'Not mapped',
line.configured ? line.amountInRupees : ''
]);
row.getCell(1).alignment = { wrapText: true };
row.getCell(2).numFmt = '#,##0';
row.getCell(3).numFmt = '#,##0.00';
// The accounts behind the head, indented under it, so every figure traces to the ledger.
for (const account of line.accounts) {
const portion = this.isPartial(account) ? ` (${account.weightPercentage}% share)` : '';
const sub = sheet.addRow([
` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`,
account.excluded ? 'Excluded' : account.balance,
account.excluded ? '' : account.balanceInRupees
]);
sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } };
sub.getCell(2).numFmt = '#,##0';
sub.getCell(3).numFmt = '#,##0.00';
}
}
sheet.addRow([]);
const totalKey = name === 'Liabilities' ? 'totalLiabilities' : 'totalAssets';
const total = sheet.addRow([
`TOTAL ${name.toUpperCase()}`,
this.total(totalKey),
''
]);
total.font = { bold: true };
total.getCell(2).numFmt = '#,##0';
boxAllCells(sheet, start, sheet.rowCount, 3);
}
/**
* One sheet per "specify" list, exactly as the RBI workbook carries them.
*
* Each account mapped to the head IS a row on its sheet, under its own name — the same accounts
* already listed under the head on the side sheet, gathered here because the template asks for
* them on their own page.
*/
private addSpecifySheets(wb: ExcelJS.Workbook): void {
const sheets: { name: string; title: string; key: string }[] = [
{ name: 'Other Funds And Reserves', title: 'Other Funds And Reserves - Specify', key: 'FORM9_L2_G_OTHER_FUNDS' },
{
name: 'Demand Liabilities',
title: 'Demand Liabilities Others Borrowing From Banks - Specify',
key: 'FORM9_L4_C_BORROWINGS_DEMAND'
},
{ name: 'Other Demand Liabilities', title: 'Other Demand Liabilities - Specify', key: 'FORM9_L4_D_OTHER_DEMAND' },
{
name: 'Time Liabilities',
title: 'Time Liabilities Others Borrowing From Banks - Specify',
key: 'FORM9_L5_C_BORROWINGS_TIME'
},
{ name: 'Other Time Liabilities', title: 'Other Time Liabilities - Specify', key: 'FORM9_L5_E_OTHER_TIME' },
{ name: 'Others Investments', title: 'Others Investments - Specify', key: 'FORM9_A4_H_OTHER_INVESTMENTS' }
];
const all = [
...this.data!.liabilityLines,
...this.data!.assetLines
];
for (const spec of sheets) {
const line = all.find((l) => l.configKey === spec.key);
const sheet = wb.addWorksheet(spec.name);
sheet.columns = [
{ width: 8 },
{ width: 52 },
{ width: 20 }];
addBanner(sheet, spec.title, 'C');
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'Sr No.',
'Particulars',
'Amount'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const accounts = (line?.accounts ?? []).filter((a) => !a.excluded);
accounts.forEach((account, i) => {
const row = sheet.addRow([
i + 1,
account.name,
account.balance
]);
row.getCell(3).numFmt = '#,##0';
});
// An empty sheet still prints its heading and a nil total: the template carries the sheet
// whether or not the bank has anything to put on it.
const total = sheet.addRow([
'',
'Total',
line?.configured ? line.amount : 0
]);
total.font = { bold: true };
total.getCell(3).numFmt = '#,##0';
boxAllCells(sheet, start, sheet.rowCount, 3);
}
}
/** The stated splits and explanations — on the form's face, entering no total. */
private addMemoSheet(wb: ExcelJS.Workbook): void {
if (!this.data!.memoLines.length) {
return;
}
const sheet = wb.addWorksheet('Explanations');
sheet.columns = [
{ width: 64 },
{ width: 20 }];
addBanner(sheet, 'Stated splits and explanations', 'B');
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'Particulars',
'Rs. in Thousands'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of this.data!.memoLines) {
const row = sheet.addRow([
line.label,
line.configured ? line.amount : 'Not mapped'
]);
row.getCell(1).alignment = { wrapText: true };
row.getCell(2).numFmt = '#,##0';
}
const overdue = sheet.addRow([
'Percentage of overdues to total loans and advances',
this.percent(this.total('overduePercentage'))
]);
overdue.font = { bold: true };
const priority = sheet.addRow([
'Priority sector advances as a percentage of total loans',
this.percent(this.total('prioritySectorPercentage'))
]);
priority.font = { bold: true };
boxAllCells(sheet, start, sheet.rowCount, 2);
}
/**
* The signatory block, left blank for signing.
*
* Deliberately not filled from anywhere: who signs a filed return and on what date is a decision
* the bank makes when it files, and pre-filling a name would put a signature on a form nobody had
* agreed to sign.
*/
private addSignatorySheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Signatory Information');
sheet.columns = [
{ width: 24 },
{ width: 34 },
{ width: 34 }];
addBanner(sheet, 'Signatory Information', 'C');
const start = sheet.rowCount + 1;
const head = sheet.addRow([
'',
'Authorised Reporting Official',
'Countersigned By'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const field of [
'Name',
'Designation',
'e-mail ID',
'Tel. No. (O)',
'Tel. No. (R)',
'Place',
'Date'
]) {
const row = sheet.addRow([
field,
'',
''
]);
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
}
boxAllCells(sheet, start, sheet.rowCount, 3);
}
private keyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
const row = sheet.addRow([
label,
value
]);
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
}
}
@@ -10,14 +10,7 @@ import { inject } from '@angular/core';
import { Observable, catchError, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { RbiCircularsService } from '../rbi-circulars.service';
import {
addBanner,
boxAllCells,
downloadWorkbook,
newWorkbook,
solidFill,
XL
} from '../oss-workbook';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
/** One GL account behind a return line. */
export interface OssAccount {
@@ -69,12 +69,24 @@ const routes: Routes = [
loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent),
data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' }
},
// Form 9 (SLR) has no route of its own. Section 24 is settled on the statutory Form 1 at Part C,
// charged on the same liabilities Part B charges the cash reserve on. A second screen computing
// the same ratio from a month-end base disagreed with it, and both were headed Form 9 — whichever
// a filer read, they had no way to know the other said something else.
{
// SLR return, filed at the end of each month.
path: 'rbi/form9',
loadComponent: () => import('./reserve-return/form9-slr.component').then((m) => m.Form9SlrComponent),
data: { title: 'Form 9 — Statutory Liquidity Ratio', breadcrumb: 'Form 9 (SLR)' }
// Form IX — the Return on Assets and Liabilities, code R408, filed monthly.
//
// A DIFFERENT return from Form 9 (SLR) above despite the name. That one reports one ratio;
// this one reports the whole balance sheet, in rupees thousands, and its two sides must
// agree. Its own route rather than a tab on rbi/form9 for exactly that reason — they answer
// different questions and share no figures.
path: 'rbi/form9-return',
loadComponent: () => import('./form9-return/form9-return.component').then((m) => m.Form9ReturnComponent),
data: { title: 'Form IX — Return on Assets and Liabilities', breadcrumb: 'Form IX (R408)' }
}
// The statutory Form 1 has no route of its own: it is a tab on rbi/form1. Both are Form 1 for
// one bank and one period, and two routes made a filer guess which held which — one could be
// read while the other was believed to be on screen.
])
];
+78 -1
View File
@@ -71,11 +71,17 @@ export class RbiCircularsService {
* @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend.
* @param officeId Optional — scopes every figure to one office.
*/
getForm1(reportingDate?: string, officeId?: number): Observable<any> {
getForm1(reportingDate?: string, baseDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
// The base date can be given outright. The 45-day lag lands where it lands, and a bank reading
// an earlier fortnight — or correcting a filing — needs to name the date the base is struck at
// rather than work backwards from it. Left blank it is derived from the lag as before.
if (baseDate) {
params = params.set('baseDate', baseDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
@@ -97,6 +103,44 @@ export class RbiCircularsService {
return this.http.get('/rbireservereturn/form9', { params });
}
/**
* Form 9 in the shape it is filed — the statutory statement of compliance with section 24.
*
* A different statement from `getForm9`, not a reformatting: three dated columns rather than one,
* and the ratio charged on the last Friday of the second preceding fortnight rather than on the
* month end itself.
*/
getForm9Statutory(reportingDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form9-statutory', { params });
}
/**
* Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408.
*
* A different return from `getForm9` despite the name: that one reports the SLR ratio, this one
* reports the whole balance sheet. Every figure comes back in rupees thousands, with the rupee
* figure alongside it so a line can still be reconciled against the ledger.
*
* @param reportingDate yyyy-MM-dd — any date in the month; the return reports that month's end.
*/
getForm9Return(reportingDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form9-return', { params });
}
/**
* OSS 1 — Return on Assets and Liabilities, computed live from the ledger.
*
@@ -121,6 +165,39 @@ export class RbiCircularsService {
return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) });
}
/**
* Form 1 as a co-operative bank files it — Banking Regulation (Co-operative Societies) Rules
* 1966, Rule 5, sections 18(1) and 24(3).
*
* A different statement from `getForm1`, not a reformatting: three columns rather than one, both
* ratios settled on the same liabilities, and those liabilities taken from the last Friday of the
* second preceding fortnight rather than a 45-day lag.
*/
getForm1Statutory(
reportingDate?: string,
firstColumnDate?: string,
secondColumnDate?: string,
officeId?: number
): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
// Both fortnight closes can be given outright. A bank filing August wants 15/08 and 31/08 on
// the face of the form whatever today is, and one date cannot produce a pair that spans a month
// boundary or skips a fortnight — which a late or corrected filing needs.
if (firstColumnDate) {
params = params.set('firstColumnDate', firstColumnDate);
}
if (secondColumnDate) {
params = params.set('secondColumnDate', secondColumnDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form1-statutory', { params });
}
// ── Saved returns ──
/**
@@ -8,9 +8,11 @@
import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { Observable, catchError, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReserveReturnBase } from './reserve-return.base';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
import { Form1Part, Form1Statutory, ReserveReturnBase } from './reserve-return.base';
/**
* RBI Form 1 — Cash Reserve Ratio, filed twice a month.
@@ -18,11 +20,20 @@ import { ReserveReturnBase } from './reserve-return.base';
* The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities
* against the cash and bank balances held today. The two sides are deliberately read on different
* dates — see the basis panel on the screen, which states both.
*
* The statutory Form 1 — the return as a co-operative bank actually files it, three dated columns
* with both ratios settled on one base — is a tab on this same screen rather than a page of its
* own. Both are Form 1 for the same bank and the same period; splitting them across two routes
* meant a filer had to know which URL held which, and could read one while believing they were
* looking at the other.
*/
@Component({
selector: 'mifosx-form1-crr',
templateUrl: './reserve-return.component.html',
styleUrls: ['./reserve-return.component.scss'],
styleUrls: [
'./reserve-return.component.scss',
'./form1-statutory.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -30,14 +41,98 @@ import { ReserveReturnBase } from './reserve-return.base';
})
export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form1_CRR';
protected readonly formCode = 'FORM_1';
// The statutory form's code, because that is what this screen saves and therefore what its saved
// list must ask for. Left as FORM_1 the list filtered on a code nothing writes any more and came
// back empty, which reads as "nothing has ever been saved" rather than as a mismatch.
protected readonly formCode = 'FORM_1_STATUTORY';
/** Returns filed before the screen moved to the statutory form, so they stay in the list. */
protected override readonly legacyFormCodes = ['FORM_1'];
override readonly hasStatutoryForm = true;
override readonly hasBaseDate = true;
/**
* The statutory form is the only view of Form 1.
*
* There were two: a working report charging the ratio on a 45-day lag, and the statutory form
* charging it on the last Friday of the second preceding fortnight. Both were headed Form 1, both
* were computed from the same ledger, and they disagreed — because the rules they follow differ.
* One screen showing two different answers to "what is our CRR position" is worse than no screen:
* whichever a filer read, they had no way to know the other existed and said something else. What
* gets filed is the statutory form, so that is what the screen shows.
*/
override activeTab: 'report' | 'statutory' | 'saved' = 'statutory';
protected override fetchStatutory(): Observable<any> {
return this.rbiService.getForm1Statutory(
this.statutoryDate,
this.statutoryFirstColumn || undefined,
this.statutorySecondColumn || undefined
);
}
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm1(reportingDate);
return this.rbiService.getForm1(reportingDate, this.baseDate || undefined);
}
ngOnInit(): void {
this.load();
// No load() here — the screen asks for a reporting date first, because which fortnight the
// return covers and which base date it reads follow from that one field.
this.loadSaved();
}
/**
* The fortnight the chosen date falls in, shown beside the picker so a filer sees which period
* they are about to run. Fortnights close on the 15th and on the month end.
*/
get statutoryFortnight(): string {
if (!this.statutoryDate) {
return '';
}
const d = new Date(this.statutoryDate + 'T00:00:00');
const first = d.getDate() <= 15;
const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
return `${first ? 1 : 16}–${end} ${month}`;
}
/**
* Fills both columns with a whole month's fortnight closes — 15th and month end — which is the
* pair a filed return almost always carries.
*/
fillStatutoryMonth(): void {
const d = this.statutoryDate ? new Date(this.statutoryDate + 'T00:00:00') : new Date();
const year = d.getFullYear();
const month = d.getMonth() + 1;
const monthEnd = new Date(year, month, 0).getDate();
this.statutoryFirstColumn = this.asDate(year, month, 15);
// Filling the CURRENT month would put a month end that has not arrived into the second column,
// and the form would be refused the moment it was run. Capped at today, so the quick fill
// always produces a pair that can actually be reported.
const filled = this.asDate(year, month, monthEnd);
this.statutorySecondColumn = filled > this.today ? this.today : filled;
this.statutoryDate = this.statutorySecondColumn;
}
/** Clears both columns, so they are derived from the reporting date instead. */
clearStatutoryColumns(): void {
this.statutoryFirstColumn = '';
this.statutorySecondColumn = '';
}
private asDate(year: number, month: number, day: number): string {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
/**
* Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
*
* Saved under its own form code rather than FORM_1: a saved return is rendered from its stored
* snapshot, and a statutory statement filed under the working report's code would be opened by
* the reader built for the other shape.
*/
protected override saveableReturn(): { formCode: string; reportingDate: string; report: any } | null {
return this.statutory
? { formCode: this.statutory.formCode, reportingDate: this.statutory.reportingDate, report: this.statutory }
: null;
}
}
@@ -0,0 +1,377 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1353a0;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
$green: #137333;
$amber: #b06000;
$red: #c5221f;
.f1s-container {
max-width: 1180px;
margin: 0 auto;
padding: 20px 16px;
}
.f1s-loading {
padding: 36px;
text-align: center;
color: $gray-500;
font-size: 13px;
}
.f1s-alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 12.5px;
margin-bottom: 16px;
line-height: 1.6;
&--error {
background: #fce8e6;
color: $red;
}
&--warn {
background: #fef7e0;
color: $amber;
}
}
.f1s-unmet {
margin: 8px 0 0 18px;
padding: 0;
}
// ── Date prompt ──
// Shown before anything is fetched. Centred and roomy rather than a strip at the top, because at
// this point choosing the date is the whole of the screen's job.
.f1s-prompt {
max-width: 640px;
margin: 48px auto;
background: #fff;
border: 1px solid $border;
border-radius: 12px;
padding: 32px;
text-align: center;
&__title {
margin: 0 0 6px;
font-size: 18px;
font-weight: 700;
color: $gray-900;
}
&__sub {
margin: 0 0 24px;
font-size: 12.5px;
color: $gray-500;
line-height: 1.7;
}
&__row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
&__date {
width: 220px;
}
&__hint {
margin: 14px 0 0;
font-size: 12.5px;
color: $primary;
}
&__quick {
margin-top: 18px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: $gray-500;
button {
font-size: 12px !important;
min-width: 0 !important;
}
}
}
// The strip above the sheet when the statutory form is shown as a tab: says what period is on
// screen and offers the way back to the date prompt.
.f1s-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
&__date {
font-size: 12.5px;
font-weight: 600;
color: $primary;
}
}
// ── Toolbar ──
.f1s-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
&__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: $gray-900;
}
&__ref {
margin: 2px 0 0;
font-size: 12px;
color: $gray-500;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
i {
margin-right: 6px;
}
}
}
.f1s-date {
width: 180px;
}
// ── The sheet ──
// Bordered and squared off rather than card-like: this is a statutory return that gets printed and
// signed, and it should read on screen as the same document that comes out of the printer.
.f1s-sheet {
background: #fff;
border: 1px solid $gray-900;
padding: 20px;
&__head {
text-align: center;
border-bottom: 2px solid $gray-900;
padding-bottom: 12px;
margin-bottom: 16px;
}
&__title {
font-size: 15px;
font-weight: 700;
color: $gray-900;
text-transform: uppercase;
}
&__ref {
margin-top: 4px;
font-size: 12px;
color: $gray-500;
}
}
.f1s-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th,
td {
border: 1px solid $gray-200;
padding: 6px 10px;
vertical-align: top;
}
th {
background: $gray-50;
font-weight: 700;
font-size: 11.5px;
color: $gray-900;
text-align: center;
border-color: $gray-900;
}
&__particulars {
text-align: left !important;
width: 52%;
}
&__num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
// A column heading is a date, not a figure, and sits centred over the column it heads. Qualified
// with `th` so it beats `&__num` above on specificity rather than on source order.
th#{&}__num {
text-align: center;
}
// The second line of a heading — what the column IS, under the date it carries. Without it the
// three dates read as three closes, and the base the ratios are charged on looks like one more
// reporting date.
&__colnote {
display: block;
margin-top: 2px;
font-weight: 400;
font-size: 9.5px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: $gray-500;
}
}
// Part banner — PART A / PART B / PART C.
.f1s-part td {
background: $primary;
color: #fff;
font-weight: 700;
font-size: 12px;
letter-spacing: 0.03em;
border-color: $primary;
}
// Section caption inside a part, e.g. "(a) Demand Liabilities".
.f1s-heading td {
background: $gray-50;
font-weight: 700;
}
.f1s-total td {
font-weight: 700;
background: #e8f0fe;
}
.f1s-total--result td {
background: #e6f4ea;
}
// A shortfall reads as one rather than as a number with a minus sign.
.f1s-neg {
color: $red;
}
.f1s-subnote td {
font-size: 11px;
font-style: italic;
color: $gray-500;
background: #fffdf5;
}
// The form nests to three levels — (a), then (i), then the figure.
.f1s-indent-1 {
padding-left: 26px !important;
}
.f1s-indent-2 {
padding-left: 46px !important;
}
.f1s-badge {
margin-left: 8px;
font-size: 10px;
font-weight: 600;
background: #fef7e0;
color: $amber;
border-radius: 20px;
padding: 1px 8px;
white-space: nowrap;
}
.f1s-sign {
margin-top: 56px;
text-align: right;
&__line {
display: inline-block;
border-top: 1px solid $gray-900;
padding-top: 6px;
font-size: 12px;
font-weight: 600;
min-width: 220px;
text-align: center;
}
}
.f1s-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: $gray-900;
color: #fff;
font-size: 12.5px;
padding: 10px 18px;
border-radius: 8px;
z-index: 1000;
}
// ── Print ──
// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. The sheet is
// already laid out as the filed form, so printing only has to drop the controls and keep the
// shading that carries meaning.
@media print {
.f1s-noprint {
display: none !important;
}
.f1s-container {
max-width: none;
padding: 0;
}
.f1s-sheet {
border: none;
padding: 0;
}
.f1s-table thead {
display: table-header-group;
}
.f1s-table tr {
break-inside: avoid;
}
.f1s-toast {
display: none !important;
}
.f1s-container,
.f1s-sheet,
.f1s-table,
th,
td {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -1,43 +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/.
*/
import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReserveReturnBase } from './reserve-return.base';
/**
* RBI Form 9 — Statutory Liquidity Ratio, filed at the end of the month.
*
* Unlike Form 1 there is no lag: both the liability base and the SLR-eligible investments are read
* as at month end, so the return states the position actually held at close. Any date the user
* picks resolves to that month's end.
*/
@Component({
selector: 'mifosx-form9-slr',
templateUrl: './reserve-return.component.html',
styleUrls: ['./reserve-return.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Form9SlrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form9_SLR';
protected readonly formCode = 'FORM_9';
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm9(reportingDate);
}
ngOnInit(): void {
this.load();
this.loadSaved();
}
}
@@ -7,7 +7,7 @@
*/
import { inject } from '@angular/core';
import { Observable, catchError, of } from 'rxjs';
import { Observable, catchError, forkJoin, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { RbiCircularsService } from '../rbi-circulars.service';
import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
@@ -19,6 +19,15 @@ export interface ReserveLine {
configured: boolean;
asOnDate: string;
amount: number | null;
/**
* The same head read at both of the return's dates, so a row can be compared across them. The
* return is built from figures struck at two different dates — the base at its lagged date, the
* holdings at the reporting date — and without both a reader cannot tell a real movement from the
* two dates simply being different.
*/
baseAmount?: number | null;
currentAmount?: number | null;
movement?: number | null;
/**
* The accounts the figure was derived from. `balance` is null and `excluded` set when an account
* is still pinned to the role but no longer countable — disabled, or turned into a header whose
@@ -45,6 +54,66 @@ export interface ReserveLineAccount {
liabilityClass?: string | null;
balance: number | null;
excluded?: 'disabled' | 'header';
/** The mapping row this came from — one account can be mapped to a head more than once. */
mappingId?: number;
/**
* The same account read at each of the return's two dates, so the breakdown explains both
* columns rather than only the one its line is struck at. Null where the account was not mapped
* at the other date — it did not hold nothing, it was not on the return.
*/
baseBalance?: number | null;
currentBalance?: number | null;
baseAccountBalance?: number | null;
currentAccountBalance?: number | null;
movement?: number | null;
}
// ── The statutory Form 1 ──
//
// Shapes for the return as a co-operative bank files it: three dated columns, Part A establishing
// the liabilities and Parts B and C settling the two ratios on that same base. It is shown as a tab
// on the Form 1 screen rather than as a page of its own.
/** One printed row: a heading, a figure line, or a total. */
export interface Form1Row {
label: string;
configKey?: string | null;
indent?: number;
heading?: boolean;
total?: boolean;
configured?: boolean;
values?: number[];
/** On the base-date row, the date each column's liabilities were read at. */
baseDates?: string[];
}
export interface Form1Part {
title: string;
lines: Form1Row[];
required?: number[];
maintained?: number[];
surplus?: number[];
baseDates?: string[];
baseDateLiabilities?: number[];
}
export interface Form1Statutory {
formCode: string;
formName: string;
statutoryReference: string;
reportingDate: string;
/** The two fortnight closes and the reporting date, in time order. */
columns: string[];
crrPercentage: number | null;
slrPercentage: number | null;
partA: Form1Part;
/**
* Part B settles section 18 — the cash reserve. Absent on a return that settles only section 24,
* where printing it would put a CRR compliance statement on an SLR form.
*/
partB?: Form1Part;
partC: Form1Part;
unmetRequirements: string[];
}
/** One row of the Memorandum or an Annexure — a stated figure, entering no total. */
@@ -106,6 +175,9 @@ export interface ReserveReturn {
note: string;
};
lines: ReserveLine[];
/** The two dates the return is struck at, so the table can head its columns with them. */
baseDate?: string;
currentDate?: string;
/** Form A's Memorandum and its two Annexures — sections of the return, not lines on it. */
memorandum?: SectionRow[];
annexureA?: SectionRow[];
@@ -162,6 +234,90 @@ export abstract class ReserveReturnBase {
/** The reporting date the user is looking at. Editable, so past periods can be reviewed. */
reportingDate = new Date().toISOString().slice(0, 10);
/**
* The date the liability base is struck at, given outright.
*
* The 45-day lag lands where it lands, and a bank reading an earlier fortnight — or correcting a
* filing — needs to name the date rather than work backwards from it. Left blank it is derived
* from the lag as before, and the return says on its face which of the two it used.
*
* Form 9 ignores it: there both sides are struck at the month end, so there is no second date to
* choose.
*/
baseDate = '';
/** Whether this return has a base date distinct from its reporting date. Form 9 does not. */
readonly hasBaseDate: boolean = false;
/**
* Nothing is fetched until a date is chosen.
*
* The return is dated: which fortnight it covers, which lagged date the base is read at and which
* rate is in force all follow from it. Opening straight onto today's figures invites a filer to
* read a return for a period they never asked for, so the screen asks first.
*/
awaitingDate = true;
/**
* What the prompt calls this return, before any data has been fetched to name it. Once the return
* runs its own `formName` is used instead.
*/
get promptTitle(): string {
return this.formCode === 'FORM_9' ? 'Form 9 — Statutory Liquidity Ratio' : 'Form 1 — Cash Reserve Ratio';
}
/** Runs the return for the chosen date. */
generate(): void {
if (!this.reportingDate) {
this.errorMsg = 'Choose a reporting date first.';
return;
}
// `max` on the input stops the picker, not a typed or pasted date, and a future one returns
// today's figures under a heading that has not happened rather than failing.
if (this.reportingDate > this.today) {
this.errorMsg = `The reporting date (${this.reportingDate}) has not happened yet — today is ${this.today}.`;
return;
}
if (this.baseDate && this.baseDate > this.today) {
this.errorMsg = `The base date (${this.baseDate}) has not happened yet — today is ${this.today}.`;
return;
}
this.awaitingDate = false;
this.load();
}
/**
* Back to the date prompt, so a filer can run a different period. Both dates are kept as entered:
* moving from one fortnight to the next usually means editing one field, not retyping both.
*/
changeDate(): void {
this.awaitingDate = true;
this.data = null;
this.errorMsg = '';
}
/**
* The fortnight the chosen date falls in, shown beside the picker so a filer sees which period
* they are about to run. Fortnights close on the 15th and on the month end.
*/
get chosenFortnight(): string {
if (!this.reportingDate) {
return '';
}
const d = new Date(this.reportingDate + 'T00:00:00');
const first = d.getDate() <= 15;
const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
return `${first ? 1 : 16}–${end} ${month}`;
}
/** Jumps the picker to a fortnight close, which is what a filed return is usually dated. */
setToFortnightClose(which: 'first' | 'second'): void {
const d = this.reportingDate ? new Date(this.reportingDate + 'T00:00:00') : new Date();
const day = which === 'first' ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
this.reportingDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
toastMsg = '';
toastVisible = false;
private toastTimer: any;
@@ -179,12 +335,138 @@ export abstract class ReserveReturnBase {
* 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';
activeTab: 'report' | 'statutory' | 'saved' = 'report';
showTab(tab: 'report' | 'saved'): void {
showTab(tab: 'report' | 'statutory' | 'saved'): void {
this.activeTab = tab;
}
/**
* Whether this screen also offers the statutory Form 1 — the return as a co-operative bank files
* it, three dated columns with both ratios settled on one base. Only Form 1 does; Form 9 has no
* statutory counterpart in these rules, so its subclass leaves this false.
*/
readonly hasStatutoryForm: boolean = false;
/**
* Whether the statutory form asks for two fortnight closes or one reporting date.
*
* Form 1 is filed fortnightly and reports two closes side by side, so a filer names both. Form 9
* is filed monthly: the month it covers settles which columns it prints, and asking for two dates
* there would let a filer name a pair that is not a month.
*/
readonly statutoryTakesTwoColumns: boolean = true;
/** What the statutory form is called on its tab, so the two returns cannot be confused. */
readonly statutoryTabLabel: string = 'Form 1';
/** What the statutory form is called on its own prompt and button. */
readonly statutoryTitle: string = 'Form 1 — Sections 18(1) and 24(3)';
readonly statutorySubtitle: string =
'Banking Regulation (Co-operative Societies) Rules, 1966. The form reports two fortnight closes ' +
'side by side — for August that is 15/08 and 31/08 — and charges both ratios on the last Friday ' +
'of the second preceding fortnight.';
// ── Statutory form state ──
//
// Held here rather than on Form 1 because Form 9 now carries a statutory form too, and the
// template they share reads these directly.
/** The statutory return, fetched only when its tab is first opened. */
statutory: Form1Statutory | null = null;
statutoryLoading = false;
statutoryError = '';
/**
* Nothing is fetched for the statutory form until a date is chosen. Which period it covers, which
* columns it prints and which base date the ratios are charged on all follow from that one field.
*/
statutoryAwaitingDate = true;
statutoryDate = new Date().toISOString().slice(0, 10);
/** The two fortnight closes the form's first two columns report. Unused on a monthly return. */
statutoryFirstColumn = '';
statutorySecondColumn = '';
/** Fetches the statutory form. Overridden by each return that has one. */
protected fetchStatutory(): Observable<any> {
return of(null);
}
/** Runs the statutory return for the dates entered. */
generateStatutory(): void {
if (this.statutoryTakesTwoColumns) {
if (!this.statutoryFirstColumn || !this.statutorySecondColumn) {
this.statutoryError = 'Enter both fortnight closing dates — for August that is 15/08 and 31/08.';
return;
}
if (this.statutoryFirstColumn >= this.statutorySecondColumn) {
// The form reads left to right as the period advances. Reversed, every movement on it would
// carry the wrong sign while still looking like a properly filled return.
this.statutoryError = 'The first column must fall before the second.';
return;
}
if (this.statutorySecondColumn > this.today) {
this.statutoryError = `The second column (${this.statutorySecondColumn}) has not happened yet — today is ${this.today}.`;
return;
}
// The return is made up to the later of the two closes, so the reporting date follows from
// them rather than being a third date a filer has to keep consistent with the first two.
this.statutoryDate = this.statutorySecondColumn;
} else {
if (!this.statutoryDate) {
this.statutoryError = 'Choose a reporting date first.';
return;
}
if (this.statutoryDate > this.today) {
this.statutoryError = `The reporting date (${this.statutoryDate}) has not happened yet — today is ${this.today}.`;
return;
}
}
this.statutoryAwaitingDate = false;
this.statutoryLoading = true;
this.statutoryError = '';
this.fetchStatutory()
.pipe(
catchError((err: any) => {
this.statutoryError =
err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute the statutory return from the ledger.';
return of(null);
})
)
.subscribe((res: Form1Statutory | null) => {
this.statutoryLoading = false;
this.statutory = res;
});
}
/** Back to the date prompt, so a filer can run a different period. */
changeStatutoryDate(): void {
this.statutoryAwaitingDate = true;
this.statutory = null;
this.statutoryError = '';
}
/** All three parts in printing order, so the template walks one list. */
get statutoryParts(): Form1Part[] {
if (!this.statutory) {
return [];
}
// partB is absent on a return that settles only section 24, so it is skipped rather than
// printed empty.
return [
this.statutory.partA,
this.statutory.partB,
this.statutory.partC
].filter((p): p is Form1Part => !!p);
}
/** A shortfall reads as one rather than as a number with a minus sign. */
isShort(value: number): boolean {
return value < 0;
}
/** The saved return currently open below the list, or null when none is. */
viewingSaved: any = null;
/**
@@ -263,23 +545,43 @@ export abstract class ReserveReturnBase {
loadSaved(): void {
this.savedLoading = true;
this.rbiService
.getSavedReturns(this.formCode)
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
// Returns saved under a code this screen no longer writes are still returns this screen filed.
// Form 1 moved from the working report's code to the statutory one, and fetching only the
// current code would hide every return saved before that — which is exactly the history a
// saved list exists to hold.
const codes = [
this.formCode,
...this.legacyFormCodes
];
forkJoin(codes.map((code) => this.rbiService.getSavedReturns(code).pipe(catchError(() => of([]))))).subscribe(
(results: any[][]) => {
this.savedLoading = false;
this.savedReturns = rows || [];
});
this.savedReturns = results
.flat()
.filter((row) => row)
.sort((a, b) => String(b.reportingDate ?? '').localeCompare(String(a.reportingDate ?? '')));
}
);
}
/**
* Codes this screen used to save under. Listed alongside the current one so nothing already
* filed drops out of the list when a form code changes.
*/
protected readonly legacyFormCodes: string[] = [];
/** Keeps the return on screen, so it can be read again later exactly as it stands now. */
saveCurrent(): void {
if (!this.data) {
// What is on screen is what gets saved. On a return whose statutory form IS the return, the
// working report is never fetched, so saving `data` would save nothing at all — and the return
// the filer was looking at could not be kept.
const snapshot = this.saveableReturn();
if (!snapshot) {
return;
}
this.saving = true;
this.rbiService
.saveReturn(this.data.formCode, this.data.reportingDate, this.data, this.savedRemarks || undefined)
.saveReturn(snapshot.formCode, snapshot.reportingDate, snapshot.report, this.savedRemarks || undefined)
.pipe(
catchError((err: any) => {
this.saving = false;
@@ -415,9 +717,59 @@ export abstract class ReserveReturnBase {
return labels[status] ?? status;
}
/** Hands the rendered return to the browser's print dialog, which also prints to PDF. */
printReturn(): void {
window.print();
/**
* The return currently on screen, in the shape the saved list stores.
*
* Overridden where the screen shows something other than the working report, so that Save keeps
* the statement the filer was actually reading rather than one they never saw.
*/
protected saveableReturn(): { formCode: string; reportingDate: string; report: any } | null {
return this.data
? { formCode: this.data.formCode, reportingDate: this.data.reportingDate, report: this.data }
: null;
}
/** Whether there is anything on screen to save — drives the Save button. */
get canSave(): boolean {
return this.saveableReturn() !== null;
}
/**
* Today, as a date input formats it — the latest date any of these returns may be run for.
*
* A balance is summed as everything up to a date, so a future one does not fail and does not come
* back empty: it returns today's figures under a heading that has not happened, and a period
* still running reads as one that has closed.
*/
readonly today = new Date().toISOString().slice(0, 10);
/**
* Whether the opened snapshot is a statutory form rather than a working report.
*
* The two are stored in the same list but are different statements: the working report carries a
* `basis` block and one amount per line, the statutory form carries three dated columns and no
* basis at all. Rendering one through the other's markup reads every missing field as blank, so
* the shape is checked before anything is drawn.
*/
get savedIsStatutory(): boolean {
return (this.savedData as any)?.formCode === 'FORM_1_STATUTORY';
}
/** The opened snapshot as a statutory form, for the template to walk its parts. */
get savedStatutory(): Form1Statutory | null {
return this.savedIsStatutory ? (this.savedData as any as Form1Statutory) : null;
}
/** The opened statutory snapshot's parts, in printing order. */
get savedStatutoryParts(): Form1Part[] {
const s = this.savedStatutory;
// partB is absent on a snapshot of a return that settles only section 24, so it is skipped
// rather than rendered as an empty part with a heading and no lines.
return s ? [
s.partA,
s.partB,
s.partC
].filter((p): p is Form1Part => !!p) : [];
}
/**
@@ -429,6 +781,30 @@ export abstract class ReserveReturnBase {
return weight !== null && weight !== undefined && weight < 100 && account.accountBalance != null;
}
/**
* The balance a weighted account's share is taken of, stated for both of the return's dates
* where they differ.
*
* A weight applies to a different balance in each column, and printing only one of them makes
* the other column's figure look as though it does not follow from the share stated beside it.
* Where the account held the same balance on both dates — or is only on the return at one of
* them — one figure says everything there is to say.
*/
portionOf(account: ReserveLineAccount): string {
const base = account.baseAccountBalance;
const current = account.currentAccountBalance;
if (base === undefined && current === undefined) {
return this.amount(account.accountBalance);
}
if (base == null || current == null) {
return this.amount(base ?? current ?? account.accountBalance);
}
if (base === current) {
return this.amount(base);
}
return `${this.amount(base)} / ${this.amount(current)}`;
}
amount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
@@ -685,7 +1061,9 @@ export abstract class ReserveReturnBase {
}
}
private addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
// Protected rather than private: a subclass exporting its own form — the statutory Form 1 — needs
// the same key/value block, and a second copy of it would drift from this one.
protected addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
const row = sheet.addRow([
label,
value
@@ -693,4 +1071,224 @@ export abstract class ReserveReturnBase {
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
}
/**
* Prints the form on its own, in a window carrying nothing else.
*
* `window.print()` on the page printed blank. The form sits inside the app shell — fixed nav
* bars, a scrolling content pane — and the print engine lays that out from the top of the
* document, so the sheet itself fell past the end of the first page with the shell's own chrome
* occupying it. Component styles could not fix it either: they are scoped to this component and
* cannot reach the shell that surrounds it.
*
* So the sheet is lifted into a window of its own with the styles it needs inlined, which is what
* the passbook screens do for the same reason.
*/
printReturn(): void {
const sheet = document.getElementById('f1s-printable');
if (!sheet) {
// Nothing generated yet — printing an empty screen would produce the blank page this exists
// to prevent.
return;
}
const win = window.open('', 'RbiStatutory', 'height=900,width=1200');
if (!win) {
// Blocked by the browser. Said out loud rather than returning quietly: a Print button that
// does nothing reads as the blank page this replaced.
this.toast('Allow pop-ups for this site to print the form.');
return;
}
const title = `${this.statutoryTabLabel} — ${this.statutory?.reportingDate ?? ''}`;
win.document.write(
`<html><head><meta charset="utf-8" /><title>${title}</title>` +
`<style>${ReserveReturnBase.PRINT_STYLES}</style></head><body>${sheet.outerHTML}` +
'<script>window.onafterprint = function () { window.close(); };' +
'window.addEventListener("load", function () { window.focus(); window.print(); });</script>' +
'</body></html>'
);
win.document.close();
win.focus();
}
/**
* The form's own styles, inlined because the print window loads no stylesheet of its own.
*
* Kept to what the form needs to read correctly on paper: the rules that carry meaning — the part
* banners, the totals, the column alignment — rather than a copy of the screen. Backgrounds are
* forced to print, because a part banner dropped to save ink takes the form's structure with it.
*/
private static readonly PRINT_STYLES = `
@page { size: A4 landscape; margin: 12mm; }
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: Roboto, Arial, sans-serif; color: #212121; margin: 0; }
.f1s-sheet { border: 1px solid #212121; padding: 16px; }
.f1s-sheet__head { text-align: center; border-bottom: 2px solid #212121; padding-bottom: 10px; margin-bottom: 12px; }
.f1s-sheet__title { font-weight: 700; font-size: 15px; text-transform: uppercase; }
.f1s-sheet__ref { font-size: 11px; color: #616161; margin-top: 3px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th, td { border: 1px solid #9e9e9e; padding: 5px 8px; vertical-align: top; }
th { background: #eee; font-weight: 700; font-size: 10.5px; text-align: center; border-color: #212121; }
.f1s-table__particulars { text-align: left; width: 52%; }
td.f1s-table__num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.f1s-table__colnote { display: block; margin-top: 2px; font-weight: 400; font-size: 8.5px;
text-transform: uppercase; color: #616161; }
/* The form nests to three levels — (a), then (i), then the figure. Flattened, a line reads as
belonging to the wrong head and the sub-totals below stop making sense. */
.f1s-indent-1 { padding-left: 24px; }
.f1s-indent-2 { padding-left: 44px; }
.f1s-part td { background: #1b5e9e; color: #fff; font-weight: 700; border-color: #1b5e9e; }
.f1s-heading td { background: #f5f5f5; font-weight: 700; }
.f1s-total td { font-weight: 700; background: #fafafa; }
.f1s-total--result td { background: #e6f4ea; }
/* A shortfall reads as one rather than as a number with a minus sign — which on a filed form
is the difference between compliant and not. */
.f1s-neg { color: #c62828; }
.f1s-subnote td { font-size: 9.5px; font-style: italic; color: #616161; background: #fffdf5; }
/* The form is signed, so the space for it belongs on the print. */
.f1s-sign { margin-top: 48px; text-align: right; }
.f1s-sign__line { display: inline-block; border-top: 1px solid #212121; padding-top: 5px;
font-size: 11px; min-width: 210px; text-align: center; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
/* An unmapped head reports nil, and a nil that nobody configured is a different thing from a
nil the bank actually holds. Kept on the printed form so a reviewer can tell them apart. */
.f1s-badge { display: inline-block; margin-left: 6px; padding: 0 4px; font-size: 8px;
font-weight: 700; text-transform: uppercase; color: #8a6100; background: #fff3cd;
border: 1px solid #e0c56b; border-radius: 3px; }
/* A form that is not ready to file says so on the paper too. Dropping this in print would hand
someone a statement that looks finished and carries a caveat only the screen ever showed. */
.f1s-alert { padding: 8px 12px; border: 1px solid #e0c56b; border-radius: 4px; font-size: 10px;
margin-bottom: 12px; background: #fff8e1; color: #8a6100; }
.f1s-unmet { margin: 6px 0 0 16px; padding: 0; }
/* Screen-only chrome that has no meaning on a filed form. */
.rr-noprint, .f1s-bar { display: none !important; }
`;
/**
* The statutory form as a workbook, laid out as the form is printed.
*
* This is the form that gets filed, so it has to leave the screen in a shape a filer can hand on:
* the same three dated columns, the same Roman-numbered lines, the same parts in the same order.
* A workbook that reorganised the form would have to be read back against the screen line by line
* before anyone could trust it.
*/
async exportStatutory(): Promise<void> {
if (!this.statutory) {
return;
}
this.generating = true;
try {
const wb = newWorkbook();
const sheet = wb.addWorksheet(this.statutoryTabLabel);
sheet.columns = [
{ width: 64 },
{ width: 20 },
{ width: 20 },
{ width: 20 }];
addBanner(sheet, this.statutory.formName, 'D');
const ref = sheet.addRow([this.statutory.statutoryReference]);
sheet.mergeCells(ref.number, 1, ref.number, 4);
ref.font = { size: 9, italic: true };
ref.alignment = { horizontal: 'center' };
sheet.addRow([]);
let start = sheet.rowCount + 1;
this.addKeyValue(sheet, 'Made up to', this.statutory.reportingDate);
this.addKeyValue(
sheet,
'CRR rate',
this.statutory.crrPercentage === null ? 'Not set' : `${this.statutory.crrPercentage}%`
);
this.addKeyValue(
sheet,
'SLR rate',
this.statutory.slrPercentage === null ? 'Not set' : `${this.statutory.slrPercentage}%`
);
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
// Anything that would make the form wrong to file goes above the figures, not below them:
// a caveat under a full statement is read after the reader has already believed it.
if (this.statutory.unmetRequirements.length) {
const warn = sheet.addRow(['Check before filing:']);
warn.font = { bold: true, color: { argb: 'FFB06000' } };
for (const reason of this.statutory.unmetRequirements) {
const r = sheet.addRow([` ${reason}`]);
r.font = { size: 9, color: { argb: 'FFB06000' } };
}
sheet.addRow([]);
}
start = sheet.rowCount + 1;
// The third column is the base the ratios are charged on, not a third close — said on the
// header so the workbook cannot be read as carrying three reporting dates.
const head = sheet.addRow([
'Particulars',
...this.statutory.columns.map((col, i) => `${col}\n${i === 2 ? 'base for ratios' : 'fortnight close'}`)
]);
head.font = { bold: true };
head.alignment = { wrapText: true, horizontal: 'center' };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const part of this.statutoryParts) {
const banner = sheet.addRow([part.title]);
sheet.mergeCells(banner.number, 1, banner.number, 4);
banner.font = { bold: true, color: { argb: XL.bannerText } };
banner.eachCell((c) => (c.fill = solidFill(XL.banner)));
for (const line of part.lines) {
// The form's own indentation carries its numbering — (a), (i), (A) — so a flattened
// sheet would lose which line belongs under which head.
const row = sheet.addRow([
`${' '.repeat(line.indent ?? 0)}${line.label}`,
...(line.values ?? []).map((v) => v)
]);
row.getCell(1).alignment = { wrapText: true };
if (line.heading || line.total) {
row.font = { bold: true };
}
for (let c = 2; c <= 4; c++) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
}
}
this.addStatutoryTotals(sheet, 'Required', part.required);
this.addStatutoryTotals(sheet, 'Maintained', part.maintained);
this.addStatutoryTotals(sheet, 'Surplus / (shortfall)', part.surplus);
}
boxAllCells(sheet, start, sheet.rowCount, 4);
await downloadWorkbook(wb, `${this.exportStem}_${this.statutory.reportingDate}`);
} finally {
this.generating = false;
}
}
/**
* Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
*
* Saved under its own form code rather than FORM_1: a saved return is rendered from its stored
* snapshot, and a statutory statement filed under the working report's code would be opened by
* the reader built for the other shape.
*/
/** One of a part's computed rows, written only where the part carries it. */
private addStatutoryTotals(sheet: ExcelJS.Worksheet, label: string, values?: number[]): void {
if (!values?.length) {
return;
}
const row = sheet.addRow([
label,
...values
]);
row.font = { bold: true };
for (let c = 2; c <= 4; c++) {
row.getCell(c).numFmt = '#,##0.00';
row.getCell(c).alignment = { horizontal: 'right' };
}
}
}
@@ -7,7 +7,88 @@
-->
<div class="rr-container">
@if (loading) {
<!--
═══ Date prompt ═══
Asked before anything is fetched: the return is dated, and which fortnight it covers, which
lagged date the base is read at and which rate is in force all follow from that one field.
-->
<!--
On a return whose statutory form IS the return, the working report is never fetched, so the tab
bar cannot live inside the `@if (data)` block below — it would never render and the statutory
form would have no way back to the saved list. Shown here instead, above everything.
-->
@if (hasStatutoryForm) {
<div class="rr-tabs rr-noprint">
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'statutory'" (click)="showTab('statutory')">
<i class="fas fa-file-alt"></i> {{ statutoryTabLabel }}
</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>
}
<!--
The whole working-report chain below — prompt, loading, error, statement — belongs to returns
that have no statutory form. Gating only its first branch left the rest of the chain live, so
Form 1 fell through to a loading state for a fetch it never makes and sat there spinning.
-->
@if (hasStatutoryForm) {
<!-- Nothing: the statutory form and the saved list render further down, on their own. -->
} @else if (awaitingDate) {
<div class="f1s-prompt">
<h2 class="f1s-prompt__title">{{ promptTitle }}</h2>
<p class="f1s-prompt__sub">
Choose the date the return is made up to. The base is read at an earlier date than the balances maintained, and
the return states both on its face once it runs.
</p>
<!--
The base date is entered outright where the return has one. The 45-day lag lands where it
lands, and a bank reading an earlier fortnight — or correcting a filing — needs to name the
date rather than work backwards from it. Left blank it is derived from the lag as before.
-->
<div class="f1s-prompt__row">
@if (hasBaseDate) {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Base date (optional)</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="baseDate" />
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="reportingDate" />
</mat-form-field>
<button mat-raised-button color="primary" (click)="generate()">Generate report</button>
</div>
@if (chosenFortnight) {
<p class="f1s-prompt__hint">
Fortnight: <strong>{{ chosenFortnight }}</strong>
@if (hasBaseDate && !baseDate) {
· base will be derived from the 45-day lag
}
</p>
}
<!-- A filed return is almost always dated to a fortnight close, so both are one click away. -->
<div class="f1s-prompt__quick">
<span>Jump to:</span>
<button mat-stroked-button (click)="setToFortnightClose('first')">15th</button>
<button mat-stroked-button (click)="setToFortnightClose('second')">Month end</button>
@if (hasBaseDate && baseDate) {
<button mat-stroked-button (click)="baseDate = ''">Clear base date</button>
}
</div>
@if (errorMsg) {
<div class="f1s-alert f1s-alert--error">{{ errorMsg }}</div>
}
</div>
} @else if (loading) {
<div class="rr-loading">Computing from the ledger…</div>
} @else if (errorMsg) {
<div class="rr-alert rr-alert--error">{{ errorMsg }}</div>
@@ -27,11 +108,19 @@
</div>
<div class="rr-header__actions">
<!-- Both dates stay editable on the report itself, so a filer can move either without
going back to the prompt and losing the figures they were reading. -->
@if (hasBaseDate) {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-date">
<mat-label>Base date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="baseDate" (change)="onDateChange()" />
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [(ngModel)]="reportingDate" (change)="onDateChange()" />
<input matInput type="date" [max]="today" [(ngModel)]="reportingDate" (change)="onDateChange()" />
</mat-form-field>
<button mat-stroked-button (click)="resetToToday()">Today</button>
<button mat-stroked-button (click)="changeDate()">Change date</button>
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
@@ -54,6 +143,10 @@
a whole statement and a month of daily rows.
-->
<div class="rr-tabs rr-noprint">
<!-- Where the return has a statutory form, that form IS the return: showing a second,
differently-based statement of the same period beside it meant two screens headed Form 1
carrying different figures, and a filer had to know which one was fileable. The working
view stays for the returns that have no statutory form of their own. -->
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'report'" (click)="showTab('report')">
<i class="fas fa-file-invoice"></i> Report
</button>
@@ -152,12 +245,20 @@
<!-- ═══ Lines ═══ -->
<div class="rr-card">
<!--
Two dated columns rather than one "Amount" against each line's own date. The return is
built from figures struck at two different dates — the base at its lagged date, the
holdings at the reporting date — and with one column a reader cannot tell a real movement
from the two dates simply being different. Every line is read at both, and the line's own
figure is marked so it is clear which of the two the return actually uses.
-->
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
<th class="rr-table__num">{{ data.baseDate || 'Base date' }}</th>
<th class="rr-table__num">{{ data.currentDate || 'Reporting date' }}</th>
<th class="rr-table__num">Movement</th>
</tr>
</thead>
<tbody>
@@ -175,10 +276,41 @@
<span class="rr-badge rr-badge--unmapped">Not configured</span>
}
</td>
<td class="rr-line__date">{{ line.asOnDate }}</td>
<td class="rr-table__num">
<!-- The cell the return's own figure comes from is tinted, so which date each line
is actually computed at stays visible now that both are shown. -->
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.baseDate">
@if (line.configured) {
{{ amount(line.amount) }}
{{
amount(
line.baseAmount !== undefined
? line.baseAmount
: line.asOnDate === data.baseDate
? line.amount
: null
)
}}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.currentDate">
@if (line.configured) {
{{
amount(
line.currentAmount !== undefined
? line.currentAmount
: line.asOnDate === data.currentDate
? line.amount
: null
)
}}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-neg]="(line.movement || 0) < 0">
@if (line.movement !== undefined && line.movement !== null) {
{{ amount(line.movement) }}
} @else {
<span class="rr-dash">—</span>
}
@@ -200,7 +332,10 @@
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) }}
<!-- The weight is one number, but the balance it applies to differs by
column. Naming only one would leave the other column's figure looking
as though it did not follow from the stated share. -->
{{ account.weightPercentage }}% of {{ portionOf(account) }}
</span>
}
<!-- Which head the portion falls under, on a liability line that carries both. -->
@@ -208,8 +343,33 @@
<span class="rr-subline__class">{{ account.liabilityClass | titlecase }}</span>
}
</td>
<td></td>
<td class="rr-table__num">{{ amount(account.balance) }}</td>
<!-- The account under both dates, matching the line above it. A head that moved
is only useful if the account that moved it can be seen. -->
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.baseDate">
@if (
account.baseBalance !== undefined ? account.baseBalance !== null : line.asOnDate === data.baseDate
) {
{{ amount(account.baseBalance !== undefined ? account.baseBalance : account.balance) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-cell--used]="line.asOnDate === data.currentDate">
@if (
account.currentBalance !== undefined
? account.currentBalance !== null
: line.asOnDate === data.currentDate
) {
{{ amount(account.currentBalance !== undefined ? account.currentBalance : account.balance) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
<td class="rr-table__num" [class.rr-neg]="(account.movement || 0) < 0">
@if (account.movement !== undefined && account.movement !== null) {
{{ amount(account.movement) }}
}
</td>
</tr>
}
}
@@ -219,16 +379,19 @@
<td>{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL)</td>
<td></td>
<td class="rr-table__num">{{ amount(data.requiredAmount) }}</td>
<td></td>
</tr>
<tr class="rr-total">
<td>{{ data.rateType }} maintained</td>
<td></td>
<td class="rr-table__num">{{ amount(data.maintainedAmount) }}</td>
<td></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>
<td></td>
</tr>
}
</tbody>
@@ -345,6 +508,204 @@
}
}
<!--
═══ Statutory Form 1 ═══
The return as a co-operative bank files it: three dated columns, Part A establishing the
liabilities, then Parts B and C settling the cash reserve and the liquid assets on that same
base. Outside the `@if (data)` block because it computes from its own date and does not depend
on the fortnightly return above having succeeded.
-->
@if (activeTab === 'statutory') {
@if (statutoryAwaitingDate) {
<div class="f1s-prompt">
<h2 class="f1s-prompt__title">{{ statutoryTitle }}</h2>
<p class="f1s-prompt__sub">{{ statutorySubtitle }}</p>
@if (statutoryTakesTwoColumns) {
<!--
A fortnightly return: both closes are entered outright, and both are required. A bank
filing August wants 15/08 and 31/08 on the face of the form whatever today is, and one
date cannot give a pair that spans a month boundary or skips a fortnight — which a late
or corrected filing needs. Deriving them silently opened the form on whatever fortnight
today fell in, with nothing on its face to say the dates had been guessed.
-->
<div class="f1s-prompt__row">
<!-- Capped at today: a close that has not happened reports today's figures under its own
heading, so the picker does not offer one. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>First column</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="statutoryFirstColumn" required />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Second column</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="statutorySecondColumn" required />
</mat-form-field>
<button
mat-raised-button
color="primary"
[disabled]="!statutoryFirstColumn || !statutorySecondColumn"
(click)="generateStatutory()"
>
Generate
</button>
</div>
<!-- The base date follows from the dates entered rather than being asked for: it is fixed
by the rules at the last Friday of the second preceding fortnight, so letting anyone
type a different one would put a figure on a filed form that the rules do not allow. -->
@if (statutoryFirstColumn && statutorySecondColumn) {
<p class="f1s-prompt__hint">
Made up to <strong>{{ statutorySecondColumn }}</strong
>. Both ratios are charged on the last Friday of the second preceding fortnight, stated on the form
itself.
</p>
} @else {
<p class="f1s-prompt__hint">Enter both fortnight closing dates to run the form.</p>
}
<!-- A filed return almost always carries one month's two closes, so both fill in one click. -->
<div class="f1s-prompt__quick">
<span>Fill:</span>
<button mat-stroked-button (click)="fillStatutoryMonth()">This month's 15th &amp; month end</button>
@if (statutoryFirstColumn || statutorySecondColumn) {
<button mat-stroked-button (click)="clearStatutoryColumns()">Clear</button>
}
</div>
} @else {
<!-- A monthly return: the month settles which columns the form prints, so one date is
enough. Asking for two here would let a filer name a pair that is not a month. -->
<div class="f1s-prompt__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="f1s-prompt__date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [max]="today" [(ngModel)]="statutoryDate" required />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="!statutoryDate" (click)="generateStatutory()">
Generate
</button>
</div>
@if (statutoryDate) {
<p class="f1s-prompt__hint">
The return reports the month ending on or before <strong>{{ statutoryDate }}</strong
>, and charges the ratio on the last Friday of the second preceding fortnight.
</p>
}
}
@if (statutoryError) {
<div class="f1s-alert f1s-alert--error">{{ statutoryError }}</div>
}
</div>
} @else if (statutoryLoading) {
<div class="f1s-loading">Computing from the ledger…</div>
} @else if (statutoryError) {
<div class="f1s-alert f1s-alert--error">{{ statutoryError }}</div>
} @else if (statutory) {
<div class="f1s-bar rr-noprint">
<span class="f1s-bar__date">Made up to {{ statutory.reportingDate }}</span>
<button mat-stroked-button (click)="changeStatutoryDate()">Change date</button>
<!-- This is the form that gets filed, so it has to be able to leave the screen. Print and
Export were on the working report only, and removing that tab took them with it. -->
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
</button>
<button mat-stroked-button [disabled]="saving" (click)="saveCurrent()">
<i class="fas fa-bookmark"></i>
{{ saving ? 'Saving…' : 'Save' }}
</button>
<button mat-raised-button color="primary" [disabled]="generating" (click)="exportStatutory()">
<i class="fas fa-file-excel"></i>
{{ generating ? 'Generating…' : 'Export' }}
</button>
</div>
<!-- Identified so printing can lift the form alone. window.print() on the page takes the whole
app shell — nav bars, tabs, toolbar — and laid the form out around them. -->
<div class="f1s-sheet" id="f1s-printable">
<div class="f1s-sheet__head">
<div class="f1s-sheet__title">{{ statutory.formName }}</div>
<div class="f1s-sheet__ref">{{ statutory.statutoryReference }}</div>
</div>
@if (statutory.unmetRequirements.length) {
<div class="f1s-alert f1s-alert--warn">
<strong>Check before filing.</strong>
<ul class="f1s-unmet">
@for (reason of statutory.unmetRequirements; track reason) {
<li>{{ reason }}</li>
}
</ul>
</div>
}
<table class="f1s-table">
<thead>
<tr>
<th class="f1s-table__particulars">Particulars</th>
<!-- The first two columns are fortnight closes; the third is the base both ratios are
charged on. Dates alone made the third read as a third close, and a reviewer had
no way to see which column the required amounts below were computed from. -->
@for (col of statutory.columns; track col; let i = $index) {
<th class="f1s-table__num">
{{ col }}
<span class="f1s-table__colnote">{{ i === 2 ? 'base for ratios' : 'fortnight close' }}</span>
</th>
}
</tr>
</thead>
<tbody>
@for (part of statutoryParts; track part.title) {
<tr class="f1s-part">
<td [attr.colspan]="statutory.columns.length + 1">{{ part.title }}</td>
</tr>
@for (line of part.lines; track line.label) {
@if (line.heading) {
<tr class="f1s-heading">
<td [attr.colspan]="statutory.columns.length + 1">{{ line.label }}</td>
</tr>
} @else {
<tr [class.f1s-total]="line.total">
<td [class]="'f1s-indent-' + (line.indent || 0)">
{{ line.label }}
@if (line.configKey && line.configured === false) {
<span class="f1s-badge">Not mapped</span>
}
</td>
@for (v of line.values; track $index) {
<td class="f1s-table__num">{{ amount(v) }}</td>
}
</tr>
<!-- The base-date row carries a different date per column, and the whole
requirement hangs off those dates, so they are printed rather than inferred. -->
@if (line.baseDates?.length) {
<tr class="f1s-subnote">
<td>Base dates — last Friday of the second preceding fortnight</td>
@for (d of line.baseDates; track $index) {
<td class="f1s-table__num">{{ d }}</td>
}
</tr>
}
}
}
@if (part.surplus?.length) {
<tr class="f1s-total f1s-total--result">
<td>Surplus / (Shortfall)</td>
@for (s of part.surplus; track $index) {
<td class="f1s-table__num" [class.f1s-neg]="isShort(s)">{{ amount(s) }}</td>
}
</tr>
}
}
</tbody>
</table>
<div class="f1s-sign">
<div class="f1s-sign__line">AUTHORISED SIGNATORY</div>
</div>
</div>
}
}
<!--
═══ Saved returns ═══
Outside the `@if (data)` block so the list stays reachable even when the live return cannot be
@@ -364,7 +725,10 @@
ledger, so a figure already reported cannot change underneath it.
</p>
@if (data?.computable) {
<!-- Gated on there being a return on screen at all, not on the working report specifically:
on Form 1 the statutory form is the return, and keying this to `data` hid the save row on
the very screen whose return most needs keeping. -->
@if (canSave) {
<div class="rr-save-row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-save-row__note">
<mat-label>Note (optional)</mat-label>
@@ -452,101 +816,149 @@
<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>
<!--
A statutory snapshot is a different statement from a working report — three dated columns
and no basis block — so it is rendered as the form it is. Through the markup below it read
as a return with every figure blank.
-->
@if (savedIsStatutory && savedStatutory; as saved) {
<div class="f1s-sheet">
<div class="f1s-sheet__head">
<div class="f1s-sheet__title">{{ saved.formName }}</div>
<div class="f1s-sheet__ref">{{ saved.statutoryReference }}</div>
</div>
<table class="f1s-table">
<thead>
<tr>
<th class="f1s-table__particulars">Particulars</th>
@for (col of saved.columns; track col; let i = $index) {
<th class="f1s-table__num">
{{ col }}
<span class="f1s-table__colnote">{{ i === 2 ? 'base for ratios' : 'fortnight close' }}</span>
</th>
}
</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>
</thead>
<tbody>
@for (part of savedStatutoryParts; track part.title) {
<tr class="f1s-part">
<td [attr.colspan]="saved.columns.length + 1">{{ part.title }}</td>
</tr>
@for (line of part.lines; track line.label) {
<tr [class.f1s-total]="line.total" [class.f1s-heading]="line.heading">
<td class="f1s-table__particulars" [style.padding-left.px]="12 + (line.indent || 0) * 18">
{{ line.label }}
</td>
@for (value of line.values || []; track $index) {
<td class="f1s-table__num">{{ amount(value) }}</td>
}
</tr>
}
}
</tbody>
</table>
</div>
} @else {
<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>
@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>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</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>
@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>
}
@@ -354,11 +354,23 @@ $red: #c5221f;
color: $gray-900;
}
&__num {
// Declared after the bare `th` and `td` rules above, and qualified with them, so a numeric
// header is not dragged back to the left by `th { text-align: left }` — which matches at the
// same specificity and would otherwise win on source order alone. A heading sitting left of the
// column it heads makes a table of figures read as though the columns were misaligned.
th#{&}__num,
td#{&}__num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
// Figures against their headings, not strung across the full width. Particulars take whatever is
// left, so the money columns stay a constant width and the eye can run down them.
&__num {
width: 1%;
min-width: 116px;
}
}
.rr-line {
@@ -454,6 +466,13 @@ $red: #c5221f;
color: $gray-500;
}
// The cell the return's own figure is taken from. Both dates are printed on every line, so without
// this a reader cannot tell which of the two the requirement was actually computed at.
.rr-cell--used {
background: #f1f6fd;
font-weight: 600;
}
// ── Sections: Memorandum, Annexures, fortnight maintenance ──
.rr-card--section {
+16
View File
@@ -76,6 +76,22 @@ const routes: Routes = [
loadComponent: () =>
import('../interest-report/interest-report.component').then((m) => m.InterestReportComponent)
},
{
path: 'interest-posting-summary',
data: { title: 'Interest Posting Summary', breadcrumb: 'Interest Posting Summary' },
loadComponent: () =>
import('../interest-posting-summary/interest-posting-summary.component').then(
(m) => m.InterestPostingSummaryComponent
)
},
{
path: 'interest-posting',
data: { title: 'Interest Posting Reports', breadcrumb: 'Interest Posting Reports' },
loadComponent: () =>
import('../interest-posting-report/interest-posting-report.component').then(
(m) => m.InterestPostingReportComponent
)
},
{
path: 'dormancy',
data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' },
@@ -52,16 +52,29 @@ export class ProductEditDiffDialogComponent implements OnInit {
productName = '';
rows: DiffRow[] = [];
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews. */
/** Request plumbing the edit form has to send, but which isn't a product setting a checker reviews.
* otherGlMappings is here too -- it is a nested {assets,liabilities,expenses,income} document, not
* a scalar, so it is handled separately by namedGlMappingRows() rather than the generic loop below
* (which would otherwise dump it as a raw, unreadable JSON blob). */
private static readonly IGNORED_KEYS = new Set([
'locale',
'dateFormat',
'monthDayFormat',
'id',
'productId',
'savingsProductId'
'savingsProductId',
'otherGlMappings'
]);
/** The two reserved otherGlMappings entries a checker actually needs to see by name -- see
* AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any
* other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no
* fixed identity to diff against by name, so those are left out of this dialog same as before. */
private static readonly NAMED_GL_MAPPINGS = [
'Savings Interest Payable',
'Savings Interest Paid'
];
/**
* Payload fields the stored product exposes under a different path, so the "current" side has
* something to compare against instead of always reading as newly set.
@@ -115,6 +128,9 @@ export class ProductEditDiffDialogComponent implements OnInit {
};
private glAccountNamesById = new Map<string, string>();
/** Same pool as glAccountNamesById, keyed by GL code instead of id -- otherGlMappings entries (see
* namedGlMappingRows) identify an account by its code, not its id. */
private glAccountNamesByCode = new Map<string, string>();
ngOnInit(): void {
this.productName = this.data.productName ?? '';
@@ -131,10 +147,11 @@ export class ProductEditDiffDialogComponent implements OnInit {
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
.concat(this.namedGlMappingRows(original, proposed))
.sort((a, b) => a.label.localeCompare(b.label));
}
/** Pools every GL account the template offers so an account id can be shown as its name. */
/** Pools every GL account the template offers so an account id/code can be shown as its name. */
private buildGlAccountLookup(template: any): void {
const optionGroups = template?.accountingMappingOptions ?? {};
for (const group of Object.values<any>(optionGroups)) {
@@ -146,13 +163,68 @@ export class ProductEditDiffDialogComponent implements OnInit {
continue;
}
const label = account.name ?? account.glCode ?? null;
if (label) {
this.glAccountNamesById.set(String(account.id), account.glCode ? `${label} (${account.glCode})` : label);
if (!label) {
continue;
}
const display = account.glCode ? `${label} (${account.glCode})` : label;
this.glAccountNamesById.set(String(account.id), display);
if (account.glCode) {
this.glAccountNamesByCode.set(String(account.glCode), display);
}
}
}
}
/**
* Diffs the two reserved otherGlMappings entries by name across every category (assets/liabilities/
* expenses/income) rather than the generic key loop above, since otherGlMappings is a nested
* {assets,liabilities,expenses,income} document whose entries move category if the maker changes
* which account type they picked -- comparing category-by-category would miss that. `original`
* carries this document as otherGlMappingsMap (falling back to the older otherGlMappings key for a
* template response that hasn't been re-fetched since), `proposed` as otherGlMappings.
*/
private namedGlMappingRows(original: any, proposed: any): DiffRow[] {
const proposedOther = proposed?.otherGlMappings;
if (proposedOther === undefined) {
// Not part of this edit at all -- nothing to compare, same as any other field the maker didn't touch.
return [];
}
const originalOther = original?.otherGlMappingsMap ?? original?.otherGlMappings;
const findByName = (doc: any, accountName: string): { glCode?: string } | null => {
const categories: any[] = doc ? Object.values(doc) : [];
for (const category of categories) {
if (!Array.isArray(category)) {
continue;
}
const match = category.find((entry: any) => entry?.accountName === accountName);
if (match) {
return match;
}
}
return null;
};
const resolve = (entry: { glCode?: string } | null): string => {
if (!entry?.glCode) {
return '—';
}
return this.glAccountNamesByCode.get(String(entry.glCode)) ?? entry.glCode;
};
return ProductEditDiffDialogComponent.NAMED_GL_MAPPINGS.map((accountName) => {
const before = findByName(originalOther, accountName);
const after = findByName(proposedOther, accountName);
return { accountName, before, after };
})
.filter(({ before, after }) => (before?.glCode ?? null) !== (after?.glCode ?? null))
.map(({ accountName, before, after }) => ({
label: accountName,
original: resolve(before),
proposed: resolve(after)
}));
}
/** Reads the current value for a payload field, following an alias or GL mapping when needed. */
private originalValueFor(key: string, original: any): any {
const glMappingKey = ProductEditDiffDialogComponent.GL_FIELD_TO_MAPPING[key];
@@ -13,15 +13,21 @@
<div>
<h2 class="tdsr__title">Deposit Interest &amp; TDS</h2>
<div class="tdsr__sub">
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
{{ scopeLabel }}
Interest each customer earned this year, and whether TDS has to be deducted on it ·
<strong>Showing: {{ scopeLabel }}</strong>
</div>
</div>
</div>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
<div class="tdsr__head-r">
<button type="button" class="tdsr__btn" (click)="showHelp = !showHelp">
<fa-icon icon="question-circle"></fa-icon>
{{ showHelp ? 'Hide help' : 'How to read this page' }}
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
@if (loadError) {
@@ -31,57 +37,87 @@
</div>
}
<!-- Help -->
@if (showHelp) {
<div class="tdsr__help">
<div class="tdsr__help-title"><fa-icon icon="info-circle"></fa-icon> How to read this page</div>
<ol class="tdsr__help-list">
<li>
Every customer with an account is listed. Each account is either
<span class="tdsr__pill tdsr__pill--ok">Tracked</span> — its interest counts toward the TDS limit — or
<span class="tdsr__pill">Not tracked</span>. Open a row (<fa-icon icon="chevron-down"></fa-icon>) to see which
account is which, and why.
</li>
<li>
Only <strong>fixed and recurring deposits</strong> are tracked. Savings interest is shown for information but
never attracts TDS.
</li>
@if (rules) {
<li>
A customer's tracked interest for the whole year, across every branch, is checked against the limit:
<strong>₹{{ rules.general | number: '1.0-0' }}</strong
>, or <strong>₹{{ rules.senior | number: '1.0-0' }}</strong> for a senior citizen (60+).
</li>
<li>
Once over the limit, TDS is <strong>{{ rules.rate | number: '1.0-2' }}%</strong> of the
<em>whole</em> year's interest — <strong>{{ rules.rateNoPan | number: '1.0-2' }}%</strong> if no PAN is on
record.
</li>
}
<li>A customer who has submitted Form 15G/15H is not deducted, even over the limit.</li>
<li>
The TDS figures here are <strong>worked out by this report</strong> from the interest actually posted. They
are not debited from the customer's account automatically.
</li>
</ol>
</div>
}
<!-- Summary -->
<div class="tdsr__kpis">
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees</div>
<div class="tdsr__kpi-l">Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on FD / RD</div>
<div class="tdsr__kpi-l">FD / RD interest — counts toward the limit</div>
</div>
<div class="tdsr__kpi tdsr__kpi--muted">
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS payable</div>
<div class="tdsr__kpi-l">Savings interest — shown only, no TDS</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
<div class="tdsr__kpi-l">Crossed the threshold</div>
<div class="tdsr__kpi-l">Customers over the limit</div>
</div>
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
</button>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS to be deducted</div>
</div>
@if (summary.noPan > 0) {
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
</div>
}
</div>
<!-- Quarter strip -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
<div class="tdsr__qstrip">
@for (q of summary.byQuarter; track q.quarter) {
<div class="tdsr__qcell">
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
</div>
<!-- Things worth acting on, only when there are any -->
@if (summary.nearThreshold || summary.declarations || summary.noPan) {
<div class="tdsr__chips">
@if (summary.nearThreshold) {
<button type="button" class="tdsr__chip tdsr__chip--near" (click)="showNearThreshold()">
{{ summary.nearThreshold | number }} near the limit (within ₹{{ nearBand | number: '1.0-0' }}) — ask for
15G/15H
</button>
}
@if (summary.declarations) {
<span class="tdsr__chip tdsr__chip--ok">
{{ summary.declarations | number }} with 15G/15H — ₹{{ summary.tdsSaved | number: '1.0-0' }} not deducted
</span>
}
@if (summary.noPan) {
<span class="tdsr__chip tdsr__chip--warn">
{{ summary.noPan | number }} FD/RD customers without PAN — higher rate applies
</span>
}
</div>
</div>
}
<!-- Filters -->
<div class="tdsr__filters">
@@ -99,8 +135,8 @@
<span>Branch</span>
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
@@ -117,32 +153,32 @@
</label>
<label class="tdsr__filter">
<span>Threshold band</span>
<span>Age group</span>
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="">Everyone</option>
<option value="GENERAL">Below 60</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<span>Search customer</span>
<input
type="text"
[(ngModel)]="filter.search"
(ngModelChange)="onFilterChanged()"
placeholder="name, CIF or PAN"
placeholder="Name, customer ID or PAN"
/>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
<span>Threshold crossed only</span>
<span>Over the limit only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
<span>Near threshold only</span>
<span>Near the limit only</span>
</label>
@if (hasFilters) {
@@ -150,7 +186,7 @@
}
</div>
<!-- Tabs Selector -->
<!-- Tabs -->
<div class="tdsr__tabs">
<button
type="button"
@@ -158,7 +194,7 @@
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
Paying TDS ({{ payingRows.length }})
TDS to deduct ({{ payingRows.length }})
</button>
<button
type="button"
@@ -166,7 +202,7 @@
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
(click)="selectTab('not-paying')"
>
Not Paying TDS ({{ notPayingRows.length }})
No TDS ({{ notPayingRows.length }})
</button>
</div>
@@ -176,87 +212,76 @@
<table class="tdsr__table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Customer</th>
<th>Branch</th>
<th>Form · Band</th>
<th class="num">Q1</th>
<th class="num">Q2</th>
<th class="num">Q3</th>
<th class="num">Q4</th>
<th class="num">Year total</th>
<th class="num">Threshold</th>
<th>Crossed</th>
<th>Accounts</th>
<th class="num">FD / RD interest this year</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS</th>
<th class="num">Saved</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of pagedRows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<tr class="tdsr__row--click" [class.tdsr__row--near]="row.nearThreshold" (click)="toggleExpanded(row)">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
<div class="mono small muted">
{{ row.deductee.customerId }} ·
ID {{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
{{ row.deductee.pan }}
PAN {{ row.deductee.pan }}
} @else {
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
}
@if (row.band === 'SENIOR') {
<span class="tdsr__pill">Senior 60+</span>
}
</div>
</td>
<td class="small">{{ branchName(row.branchCode) }}</td>
<td>
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
{{ row.form === '121' ? '121' : row.form }}
</span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
@if (row.hasDeclaration) {
<div class="mono small muted">{{ row.declarationUin }}</div>
<td class="small">{{ row.branchCode || '—' }}</td>
<td class="small">
@if (row.trackedAccounts) {
<span class="tdsr__pill tdsr__pill--ok">{{ row.trackedAccounts }} tracked</span>
}
@if (untrackedOf(row).length) {
<span class="tdsr__pill">{{ untrackedOf(row).length }} not tracked</span>
}
</td>
@for (q of quarters; track q) {
<td class="num small">
@if (row.quarters[q]) {
₹{{ row.quarters[q] | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
}
</td>
}
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
<div class="small muted">+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)</div>
}
</td>
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
<td class="small">
@if (row.crossedInQuarter) {
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
} @else if (row.nearThreshold) {
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
} @else {
<span class="muted">—</span>
}
<span
class="tdsr__pill"
[class.tdsr__pill--warn]="statusOf(row).tone === 'warn'"
[class.tdsr__pill--near]="statusOf(row).tone === 'near'"
[class.tdsr__pill--ok]="statusOf(row).tone === 'ok'"
>
{{ statusOf(row).label }}
</span>
</td>
<td class="num">
@if (row.schedule.totalTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.tdsSaved) {
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
} @else if (row.tdsSaved) {
<span class="small tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }} saved</span>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
<button
type="button"
class="tdsr__icon-btn"
[attr.aria-label]="isExpanded(row) ? 'Hide details' : 'Show details'"
(click)="$event.stopPropagation(); toggleExpanded(row)"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
</td>
@@ -264,89 +289,138 @@
@if (isExpanded(row)) {
<tr class="tdsr__row--detail">
<td colspan="13">
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
is a joint holder but not the first-named one. Tax there is deducted against the first-named
holder, so it is excluded from the figures above.
</div>
</div>
}
<td colspan="8">
<!-- 1. Which accounts are tracked -->
<div class="tdsr__detail-h">Accounts of {{ row.deductee.name }}</div>
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
<th>Account no.</th>
<th>Type</th>
<th>Tracked for TDS?</th>
<th class="num">Interest this year</th>
<th class="num">Postings</th>
<th>Last posted</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
@for (a of row.accounts; track a.accountNo) {
<tr>
<td class="mono small">{{ a.accountNo }}</td>
<td class="small">{{ accountTypeLabel[a.type] }}</td>
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
@if (a.tracked) {
<span class="tdsr__pill tdsr__pill--ok"><fa-icon icon="check"></fa-icon> Yes</span>
} @else {
<span class="muted">—</span>
<span class="tdsr__pill">No</span>
}
<span class="muted"> {{ a.reason }}</span>
</td>
<td class="num">₹{{ a.interest | number: '1.2-2' }}</td>
<td class="num small">{{ a.postings }}</td>
<td class="small muted">
{{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'No interest posted yet' }}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
<div class="tdsr__meta">
<span
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
the deposit product)</span
>
@if (row.declarationAcceptedOn) {
<span
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
>
<!-- 2. How the TDS was worked out -->
@if (row.trackedAccounts) {
<div class="tdsr__detail-h">How the TDS is worked out, quarter by quarter</div>
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.schedule.lines.length) {
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest in quarter</th>
<th class="num">Total so far</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">limit crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">15G/15H active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">
₹{{ line.cumInterest | number: '1.0-0' }}
<span class="small muted">of ₹{{ row.threshold | number: '1.0-0' }}</span>
</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="small muted">No interest has been posted on the tracked deposits this year yet.</div>
}
@if (row.paidWithoutDeduction) {
<span>
Paid without deduction:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
{{ statute.statement }}
</span>
<div class="tdsr__meta">
<span>
Paid without deduction because of 15G/15H:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — still reported in
{{ statute.statement }}
</span>
</div>
}
</div>
} @else {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
This customer has no fixed or recurring deposit, so nothing here counts toward TDS. Savings
interest is shown for information only.
</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest is on joint deposits where this
customer is not the first-named holder, so it is counted under that holder instead.
</div>
</div>
}
</td>
</tr>
}
}
@if (visibleRows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
<td colspan="8" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
@if (loading) {
Loading…
} @else if (selectedTab === 'paying') {
No customer needs TDS deducted for these filters. Check the "No TDS" tab.
} @else {
No customers match these filters.
}
</td>
</tr>
}
@@ -359,8 +433,7 @@
<div class="tdsr__pager-l">
<span>
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
<strong>{{ visibleRows.length | number }}</strong>
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
<strong>{{ visibleRows.length | number }}</strong> customers
</span>
<label class="tdsr__pager-sizel">
Rows
@@ -13,12 +13,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { setTdsDeductees, tdsFys } from './tds-reports-data';
import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data';
import { TdsReportsService } from './tds-reports.service';
import {
NEAR_THRESHOLD_BAND,
TdsAccountLine,
TdsInterestRow,
TdsInterestSummary,
TdsReportFilter,
@@ -40,7 +40,12 @@ export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
/**
* Office names the loaded customers belong to, for Head Office's branch selector. Read off the
* data rather than a fixed list, which named branches this bank does not have and left out the
* ones it does.
*/
branches: string[] = [];
/** Filled by the route resolver from Organization › TDS Configuration. */
readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
@@ -92,16 +97,26 @@ export class TdsInterestReportComponent implements OnInit {
loading = false;
loadError = '';
/** The "how to read this page" panel. */
showHelp = true;
readonly accountTypeLabel: Record<TdsAccountLine['type'], string> = {
FD: 'Fixed deposit',
RD: 'Recurring deposit',
SB: 'Savings',
OTHER: 'Other'
};
ngOnInit(): void {
if (!this.financialYears.length) {
this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.';
return;
}
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
// A branch login sees only its own book and cannot widen the scope. The platform labels each
// customer with their office's name, so the login's office name is the filter — the GL branch
// code list does not know every office and turned a branch login into an empty report.
if (!this.scope.isHeadOffice) {
this.filter.branchCode = this.scope.officeName;
}
this.loadDeductees();
}
@@ -121,6 +136,7 @@ export class TdsInterestReportComponent implements OnInit {
next: (deductees) => {
this.loading = false;
setTdsDeductees(deductees);
this.branches = this.branchesOf(deductees);
this.invalidateCaches();
},
error: (err: any) => {
@@ -138,6 +154,11 @@ export class TdsInterestReportComponent implements OnInit {
* only the summary left the KPIs counting the new data while the tables kept the empty list
* cached on the first render, before the load returned.
*/
private branchesOf(deductees: TdsDeductee[]): string[] {
const names = new Set(deductees.map((d) => d.branchCode).filter((n) => !!n));
return [...names].sort((a, b) => a.localeCompare(b));
}
private invalidateCaches(): void {
this.rowsCacheKey = '';
this.summaryCacheKey = '';
@@ -168,12 +189,54 @@ export class TdsInterestReportComponent implements OnInit {
};
}
/** The limits and rates for the selected year, for the help panel. */
get rules(): { general: number; senior: number; rate: number; rateNoPan: number } | null {
const cfg = FY_TAX_CONFIG[this.filter.fy];
if (!cfg) {
return null;
}
return {
general: cfg.thresholdGeneral,
senior: cfg.thresholdSenior,
rate: cfg.rate * 100,
rateNoPan: cfg.rateNoPan * 100
};
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeLabel(): string {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
return this.filter.branchCode ? `${this.filter.branchCode} branch` : 'All branches';
}
/**
* One plain-language status per customer, so a reader does not have to work it out from the
* threshold, crossed and saved columns together.
*/
statusOf(row: TdsInterestRow): { label: string; tone: 'muted' | 'warn' | 'near' | 'ok' } {
if (!row.trackedAccounts) {
return { label: 'Not tracked — no FD/RD', tone: 'muted' };
}
if (row.schedule.totalTds > 0) {
return { label: 'Over the limit — TDS due', tone: 'warn' };
}
if (row.hasDeclaration && row.crossed) {
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
}
if (row.nearThreshold) {
return { label: `Near the limit — ₹${Math.round(row.headroom).toLocaleString('en-IN')} left`, tone: 'near' };
}
return { label: 'Below the limit', tone: 'ok' };
}
trackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => a.tracked);
}
untrackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => !a.tracked);
}
/** Recomputes the book only when the filter actually changed. Side-effect free. */
@@ -315,10 +378,6 @@ export class TdsInterestReportComponent implements OnInit {
return this.expandedId === row.deductee.customerId;
}
branchName(code: string): string {
return glBranchName(code);
}
trackRow(_: number, row: TdsInterestRow): string {
return row.deductee.customerId;
}
@@ -337,6 +396,9 @@ export class TdsInterestReportComponent implements OnInit {
'CIF',
'PAN',
'Branch',
'Accounts tracked for TDS',
'Accounts not tracked',
'Status',
'Form',
'Band',
'Age',
@@ -361,7 +423,14 @@ export class TdsInterestReportComponent implements OnInit {
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
this.branchName(r.branchCode),
r.branchCode,
this.trackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.untrackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.statusOf(r).label,
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
@@ -389,6 +458,9 @@ export class TdsInterestReportComponent implements OnInit {
`${s.deductees} deductees`,
'',
'',
`${s.trackedCustomers} tracked`,
'',
'',
'',
'',
'',
+74 -4
View File
@@ -56,6 +56,25 @@ export interface TdsReportFilter {
nearThresholdOnly: boolean;
}
/**
* One of the customer's accounts as the report sees it: whether its interest is being counted
* toward the deduction, and if not, why not. A customer can hold savings, fixed and recurring
* accounts together, and the branch has to see which of them the deduction is actually watching.
*/
export interface TdsAccountLine {
accountNo: string;
type: 'FD' | 'RD' | 'SB' | 'OTHER';
/** True when the account's interest counts toward this customer's threshold. */
tracked: boolean;
/** Plain-language reason, shown beside the account. */
reason: string;
/** Interest credited in the year. */
interest: number;
/** How many interest postings made up that figure. */
postings: number;
lastPostedOn: string | null;
}
/** How close to the trigger counts as "worth chasing a declaration for". */
export const NEAR_THRESHOLD_BAND = 6_000;
@@ -101,6 +120,10 @@ export interface TdsInterestRow {
paidWithoutDeduction: number;
panOnRecord: boolean;
rateApplied: number;
/** Every account the customer holds, tracked or not. */
accounts: TdsAccountLine[];
/** How many of them count toward the deduction. */
trackedAccounts: number;
}
function emptyQuarters(): Record<Quarter, number> {
@@ -154,14 +177,35 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
let exemptInterest = 0;
let jointHeldInterest = 0;
const accountLines: TdsAccountLine[] = [];
for (const account of deductee.accounts) {
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
const yearCredits = account.credits.filter((c) => fyOf(c.date) === fy);
const inYear = yearCredits.reduce((s, c) => s + c.amount, 0);
const type = accountTypeOf(account.depositTypeCode);
let tracked = false;
let reason: string;
if (!isTdsApplicable(account)) {
exemptInterest += inYear;
reason = type === 'SB' ? 'Savings account — no TDS on savings interest' : 'Not a time deposit — no TDS';
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
jointHeldInterest += inYear;
reason = 'Joint deposit — counted under the first-named holder';
} else {
tracked = true;
reason = 'Counted toward the TDS limit';
}
accountLines.push({
accountNo: account.accountNo || '',
type,
tracked,
reason,
interest: inYear,
postings: yearCredits.length,
lastPostedOn: yearCredits.reduce<string | null>((last, c) => (!last || c.date > last ? c.date : last), null)
});
}
// Tracked accounts first, so the deposits that drive the figure lead the list.
accountLines.sort((a, b) => Number(b.tracked) - Number(a.tracked));
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
@@ -190,7 +234,9 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
paidWithoutDeduction: schedule.shieldedInterest,
panOnRecord: isWellFormedPan(deductee.pan),
rateApplied: schedule.rateApplied
rateApplied: schedule.rateApplied,
accounts: accountLines,
trackedAccounts: accountLines.filter((a) => a.tracked).length
});
}
@@ -210,10 +256,29 @@ function overallModeFor(deductee: TdsDeductee): TdsMode {
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
function accountTypeOf(code: string | undefined): TdsAccountLine['type'] {
const c = (code || '').toLowerCase();
if (c.includes('fixeddeposit')) {
return 'FD';
}
if (c.includes('recurringdeposit')) {
return 'RD';
}
if (c.includes('savings')) {
return 'SB';
}
return 'OTHER';
}
/** Branches are compared by office name, case and spacing aside — the platform and the session both carry the name. */
function sameBranch(a: string, b: string): boolean {
return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase();
}
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
const needle = filter.search.trim().toLowerCase();
return tdsInterestRowsForFy(filter.fy).filter((row) => {
if (filter.branchCode && row.branchCode !== filter.branchCode) {
if (filter.branchCode && !sameBranch(row.branchCode, filter.branchCode)) {
return false;
}
if (filter.band && row.band !== filter.band) {
@@ -254,6 +319,8 @@ export interface TdsInterestSummary {
tdsSaved: number;
paidWithoutDeduction: number;
noPan: number;
/** Customers holding at least one account whose interest counts toward the deduction. */
trackedCustomers: number;
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
}
@@ -280,7 +347,10 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary
declarations: rows.filter((r) => r.hasDeclaration).length,
tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0),
paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
noPan: rows.filter((r) => !r.panOnRecord).length,
// Only customers with a deposit the deduction watches: a savings-only customer without a PAN
// is never deducted at the higher rate, so counting them overstated the problem.
noPan: rows.filter((r) => !r.panOnRecord && r.trackedAccounts > 0).length,
trackedCustomers: rows.filter((r) => r.trackedAccounts > 0).length,
byQuarter
};
}
@@ -647,3 +647,82 @@
color: var(--tdsr-ink);
white-space: nowrap;
}
/* ── Help panel (interest report) ── */
.tdsr__help {
margin-bottom: 16px;
padding: 14px 18px;
border: 1px solid color-mix(in srgb, var(--tdsr-blue) 25%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--tdsr-blue) 5%, transparent);
line-height: 1.6;
}
.tdsr__help-title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
font-weight: 700;
color: var(--tdsr-blue);
}
.tdsr__help-list {
margin: 0;
padding-left: 20px;
li + li {
margin-top: 2px;
}
}
/* ── Action chips under the summary ── */
.tdsr__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -6px 0 16px;
}
.tdsr__chip {
padding: 5px 12px;
border: 1px solid var(--tdsr-line);
border-radius: 999px;
background: var(--tdsr-bg);
font: inherit;
font-size: 12px;
font-weight: 600;
&--near {
border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
color: var(--tdsr-blue);
cursor: pointer;
}
&--ok {
border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
color: var(--tdsr-green);
}
&--warn {
border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
color: var(--tdsr-amber);
}
}
.tdsr__row--click {
cursor: pointer;
&:hover > td {
background: var(--tdsr-bg);
}
}
.tdsr__detail-h {
margin: 4px 0 8px;
font-weight: 700;
.tdsr__table--inner + & {
margin-top: 16px;
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
// in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'utkarshbank';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank';
window["env"]["fineractPlatformTenantIds"] = 'utkarshbank,lincpay';
window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = '';
+1
View File
@@ -2971,6 +2971,7 @@
"Transfer Interest to Savings Account": "Transfer Interest to Savings Account",
"Transfer Type": "Transfer Type",
"Transfer in suspense": "Transfer in suspense",
"Transfer to GL Account": "Transfer to GL Account",
"Transfer to Savings": "Transfer to Savings",
"Transfer to savings account": "Transfer to savings account",
"Trigger Type": "Trigger Type",
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260928',
'hash': '6cbb31b5'
'version': '261005',
'hash': '1ffac4b1'
},
'allow_switching_backend_instance': true
};
+1 -1
View File
@@ -20,7 +20,7 @@ export const environment = {
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+1 -1
View File
@@ -23,7 +23,7 @@ export const environment = {
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,