diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html index ec7b8bf32..1fce84acf 100644 --- a/src/app/board-resolution/bor-report.component.html +++ b/src/app/board-resolution/bor-report.component.html @@ -17,7 +17,12 @@

- @@ -132,7 +137,9 @@ {{ r.approvedShares | number }} {{ r.shareValue | number: '1.2-2' }} {{ r.trancheCount }} - {{ statusText(r.statusEnum) }} + + {{ statusText(r.statusEnum) }} + {{ r.activatedDate || '—' }} } @empty { diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index 65d27eb6e..57d49000e 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -107,11 +107,26 @@ @if (canOperate) { - } + @if (interestPending.length > 0) { +
+ warning + + Savings interest posting report is not approved for {{ interestPending.join(', ') }}. Approve it in Reports → Interest Posting Reports before + closing the day. + +
+ } } @else if (loaded && canOperate) {
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index c97aebcc9..449dfced1 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -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; + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index da476f658..088380db1 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -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 diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 3078701f8..f3700e20c 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -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 { let params = new HttpParams(); diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.html b/src/app/clients/clients-view/form121-tab/form121-tab.component.html index 800f617bf..9c71e8840 100644 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.html +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.html @@ -56,8 +56,8 @@ info_outline
@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. diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index c95341dee..611abcf88 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -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 diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts index 26e07e932..d6410f748 100644 --- a/src/app/core/http/cache.interceptor.ts +++ b/src/app/core/http/cache.interceptor.ts @@ -31,7 +31,13 @@ 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' + ]; private isCacheable(url: string): boolean { return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment)); diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 6021b9998..27da072b6 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -663,6 +663,12 @@ + + diff --git a/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html b/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html index 3738be9ba..24581b1b8 100644 --- a/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html +++ b/src/app/current-accounts/current-account-view/general-tab/general-tab.component.html @@ -268,7 +268,7 @@ OD Interest Charged {{ - (currentAccountData.summary.totalOverdraftInterestDerived || 0) + currentAccountData.summary.totalOverdraftInterestDerived || 0 | currency: currency.code : 'symbol-narrow' : '1.2-2' }} diff --git a/src/app/interest-posting-report/interest-posting-report.component.html b/src/app/interest-posting-report/interest-posting-report.component.html new file mode 100644 index 000000000..6c4c92edb --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.component.html @@ -0,0 +1,190 @@ + + +
+
+
+

Interest Posting Reports

+
+ 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. +
+
+
+ + +
+
+ + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + @if (notice) { +
+ +
{{ notice }}
+
+ } + @if (pendingCount > 0) { +
+ +
{{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are approved.
+
+ } + + @if (due.length > 0) { +
+ +
+
Interest is due but no report has been generated yet for:
+ @for (d of due; track d.productId + '-' + d.officeId) { +
+ {{ d.productName }} · {{ d.officeName }} · {{ d.postingDate }} + +
+ } +
+
+ } + +
+ + + + + + + + + + + + + + + + @for (r of reports; track r.id) { + + + + + + + + + + + + } @empty { + + + + } + +
BranchProductPosting dateAccountsTotal interestStatusGeneratedApproved
{{ r.officeName }} + {{ r.productName }} + {{ r.postingDate }}{{ r.accountCount }}₹{{ r.totalInterest | number: '1.2-2' }} + {{ r.status }} + @if (r.failureReason) { +
Posting failed: {{ r.failureReason }}
+ } +
+ {{ r.generatedBy || 'System' }} · {{ r.generationMode }}
{{ r.generatedAt | date: 'dd MMM, HH:mm' }} +
+ @if (r.approvedAt) { + {{ r.approvedBy }} + @if (r.approvedOnBehalf) { + (HO) + } +
{{ r.approvedAt | date: 'dd MMM, HH:mm' }} + } @else { + — + } +
+ @if (canRegenerate(r)) { + + } + @if (canApprove(r)) { + + } +
+ @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. + } +
+
+ + @if (selected) { +
+
+
+

{{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }}

+
+ 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 +
+
+
+ Total interest + ₹{{ selected.totalInterest | number: '1.2-2' }} + {{ selected.accountCount }} account(s) +
+
+ + + + + + + + + + + + + @for (l of selected.lines; track l.savingsAccountId) { + + + + + + + + + } + + + + + + + + +
Account noAccount holderInterestBalance before postingBalance after postingPosted txn
{{ l.accountNo }}{{ l.clientName }}₹{{ l.interestAmount | number: '1.2-2' }}₹{{ l.balanceBefore | number: '1.2-2' }}₹{{ l.balanceAfter | number: '1.2-2' }}{{ l.savingsTransactionId ? '#' + l.savingsTransactionId : '—' }}
Total + ₹{{ selected.totalInterest | number: '1.2-2' }} +
+
+ } +
diff --git a/src/app/interest-posting-report/interest-posting-report.component.scss b/src/app/interest-posting-report/interest-posting-report.component.scss new file mode 100644 index 000000000..6adbcaa77 --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.component.scss @@ -0,0 +1,276 @@ +/** + * 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); +} diff --git a/src/app/interest-posting-report/interest-posting-report.component.ts b/src/app/interest-posting-report/interest-posting-report.component.ts new file mode 100644 index 000000000..d7a708060 --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.component.ts @@ -0,0 +1,200 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +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 { + InterestPostingDue, + InterestPostingReport, + InterestPostingReportDetail, + InterestPostingReportService +} 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[] = []; + + ngOnInit(): void { + this.postingDate = this.businessDateIso(); + this.load(); + } + + load(): void { + this.loading = true; + this.errorMessage = ''; + this.reportService.list(this.postingDate || undefined).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).subscribe({ + next: (due) => (this.due = due ?? []), + error: () => (this.due = []) + }); + } + + generate(item: InterestPostingDue): void { + 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.` + ) + ) { + 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.' + ) + ) { + 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' + ); + } + + get pendingCount(): number { + return this.reports.filter((r) => r.status === 'GENERATED').length; + } + + 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 + ); + } +} diff --git a/src/app/interest-posting-report/interest-posting-report.service.ts b/src/app/interest-posting-report/interest-posting-report.service.ts new file mode 100644 index 000000000..398dd2f8b --- /dev/null +++ b/src/app/interest-posting-report/interest-posting-report.service.ts @@ -0,0 +1,137 @@ +/** + * 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'; + +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; +} + +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; +} + +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; +} + +/** + * 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'; + + list(postingDate?: string, status?: string, officeId?: number): Observable { + let params = new HttpParams(); + 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(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): Observable { + let params = new HttpParams(); + if (postingDate) params = params.set('postingDate', postingDate); + return this.http.get(`${this.base}/due-without-report`, { params }); + } + + detail(reportId: number): Observable { + return this.http.get(`${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') } + ); + } + + /** The head office's branch-wise view over a date range. */ + summary(fromDate?: string, toDate?: string, productId?: number): Observable { + 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(`${this.base}/summary`, { params }); + } +} diff --git a/src/app/interest-posting-summary/interest-posting-summary.component.html b/src/app/interest-posting-summary/interest-posting-summary.component.html new file mode 100644 index 000000000..8ecca4e98 --- /dev/null +++ b/src/app/interest-posting-summary/interest-posting-summary.component.html @@ -0,0 +1,172 @@ + + +
+
+
+

Interest Posting Summary

+
+ 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. +
+
+
+ + + + + +
+
+ + @if (errorMessage) { +
+ +
{{ errorMessage }}
+
+ } + @if (pendingCount > 0) { +
+ +
{{ pendingCount }} branch report(s) in this period are not posted yet.
+
+ } + +
+
+
+

Branch totals

+
{{ fromDate }} to {{ toDate }}
+
+
+ Total interest + ₹{{ totalInterest | number: '1.2-2' }} + {{ totalCustomers }} customer(s) · posted ₹{{ postedInterest | number: '1.2-2' }} +
+
+ + + + + + + + + + + + + @for (b of byBranch; track b.officeName) { + + + + + + + + + } @empty { + + + + } + + @if (byBranch.length > 0) { + + + + + + + + + + + } +
BranchReportsCustomersInterestPostedNot yet posted
+ {{ b.officeName }} + {{ b.reports }}{{ b.customers }}₹{{ b.interest | number: '1.2-2' }}₹{{ b.posted | number: '1.2-2' }}₹{{ b.interest - b.posted | number: '1.2-2' }}
+ @if (loading) { + Loading… + } @else { + No interest posting report in this period. + } +
Total + {{ shown.length }} + + {{ totalCustomers }} + + ₹{{ totalInterest | number: '1.2-2' }} + + ₹{{ postedInterest | number: '1.2-2' }} + + ₹{{ totalInterest - postedInterest | number: '1.2-2' }} +
+
+ +
+ + + + + + + + + + + + + + + @for (r of shown; track r.id) { + + + + + + + + + + + } + +
Posting dateBranchProductCustomersInterestStatusApprovedPosted
{{ r.postingDate }}{{ r.officeName }} + {{ r.productName }} + {{ r.accountCount }}₹{{ r.totalInterest | number: '1.2-2' }} + {{ r.status }} + + @if (r.approvedAt) { + {{ r.approvedBy }} + @if (r.approvedOnBehalf) { + (HO) + } +
{{ r.approvedAt | date: 'dd MMM, HH:mm' }} + } @else { + — + } +
{{ r.postedAt ? (r.postedAt | date: 'dd MMM, HH:mm') : '—' }}
+
+
diff --git a/src/app/interest-posting-summary/interest-posting-summary.component.ts b/src/app/interest-posting-summary/interest-posting-summary.component.ts new file mode 100644 index 000000000..de973d7ac --- /dev/null +++ b/src/app/interest-posting-summary/interest-posting-summary.component.ts @@ -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(); + 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}`; + } +} diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html index 9c8f007c2..524a379c8 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -129,11 +129,30 @@
-

GL accounts behind each figure

+
+

GL accounts behind each figure

+ + + Configuring + + @for (f of formFilters; track f.value) { + {{ f.label }} + } + + +

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. + }

@if (unmappedCount) { diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss index 2660489ab..8d62d74da 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.scss @@ -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; diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts index 73a9c7de2..8fea368ae 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -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) { diff --git a/src/app/organization/tds-config/tds-config.component.html b/src/app/organization/tds-config/tds-config.component.html index ce237b314..dbf1c6bfe 100644 --- a/src/app/organization/tds-config/tds-config.component.html +++ b/src/app/organization/tds-config/tds-config.component.html @@ -61,12 +61,7 @@ } -
@@ -78,8 +73,8 @@

Financial years

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

diff --git a/src/app/organization/tds-config/tds-config.component.ts b/src/app/organization/tds-config/tds-config.component.ts index 91de87e37..77fd5e928 100644 --- a/src/app/organization/tds-config/tds-config.component.ts +++ b/src/app/organization/tds-config/tds-config.component.ts @@ -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[] = [ diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts index da3435ff5..f68d32c94 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts @@ -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 }); diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html index 910e31c4a..a7d6adee9 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html @@ -23,7 +23,9 @@ @if (!isOverdraftAllowed) {
ℹ️ - Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab. + Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.
} diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts index 4decaae32..4f41c6e41 100644 --- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts +++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.ts @@ -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 }); } diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts index 9f45429ff..c374eb301 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts @@ -88,6 +88,8 @@ export class SavingProductAccountingStepComponent implements OnInit { liabilityAccountOptions: GLAccount[] = []; expenseAccountOptions: GLAccount[] = []; incomeAccountOptions: GLAccount[] = []; + /** Savings Interest Paid nets to nil each posting, so it may be a liability clearing GL or an expense GL. */ + 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.liabilityAccountOptions.concat(this.expenseAccountOptions); this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || []; this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions); this.buildAccountingSectionConfigs(); @@ -201,26 +204,29 @@ 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 and Paid are liability rows, saved under liabilities. A product saved while they sat + // under expenses still carries them there, 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.expenseAccountOptions + this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions); + const fromExpenses = [ + SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME, + SAVINGS_INTEREST_PAID_ACCOUNT_NAME + ].reduce( + (remaining, name) => remaining.filter((mapping: any) => mapping?.accountName !== name), + glMappings?.expenses || [] ); + this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions); this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions); } } @@ -334,16 +340,14 @@ export class SavingProductAccountingStepComponent implements OnInit { SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME, this.liabilityAccountOptions ), - ...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions) - ], - expenses: [ ...namedAsOtherGlMapping( 'savingsInterestPaidAccountId', SAVINGS_INTEREST_PAID_ACCOUNT_NAME, - this.expenseAccountOptions + this.paidAccountOptions ), - ...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions) + ...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions) ], + expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions), income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions) }; @@ -378,14 +382,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 +528,24 @@ 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 + }, + { + // A pass-through between the payable and the members that closes each posting at nil, so a + // liability clearing GL and an expense GL both work; both kinds are offered. + key: 'savingsInterestPaidAccountId', + label: 'Savings Interest Paid', + inputLabel: 'Savings interest paid', + controlName: 'savingsInterestPaidAccountId', + options: this.paidAccountOptions, + required: true }, { key: 'interestPayableAccountId', @@ -555,6 +574,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', @@ -562,13 +582,6 @@ export class SavingProductAccountingStepComponent implements OnInit { options: this.expenseAccountOptions, required: true }, - { - key: 'savingsInterestPaidAccountId', - label: 'Savings Interest Paid', - inputLabel: 'Savings interest paid', - controlName: 'savingsInterestPaidAccountId', - options: this.expenseAccountOptions - }, { // Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the // 79 paise land here rather than on the member's own ledger. diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.html b/src/app/rbi-circulars/form9-return/form9-return.component.html new file mode 100644 index 000000000..98878e3e0 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-return.component.html @@ -0,0 +1,261 @@ + + +
+ + @if (awaitingDate) { +
+

Form IX — Return on Assets and Liabilities

+

+ Return code R408, filed monthly. Every figure is a closing balance as at the month end, in rupees thousands. +

+ +
+ + Reporting date + + + +
+ + @if (chosenMonth) { +

+ Month: {{ chosenMonth }} +

+ } + +
+ Jump to: + +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } +
+ } @else if (loading) { +
Computing from the ledger…
+ } @else if (errorMsg) { +
{{ errorMsg }}
+ } @else if (data) { +
+ + As at {{ data.reportingDate }} · {{ data.unit }} + + + + +
+ +
+
+
{{ data.formName }}
+
{{ data.statutoryReference }} · Return code {{ data.returnCode }}
+
As at {{ data.reportingDate }} · {{ data.unit }}
+
+ + +
+
+ Total Liabilities + {{ amount(data.reconciliation.totalLiabilities) }} +
+
+ Total Assets + {{ amount(data.reconciliation.totalAssets) }} +
+
+ Difference + + {{ amount(data.reconciliation.difference) }} + + + {{ + data.reconciliation.balanced + ? 'The two sides agree' + : rupees(data.reconciliation.differenceInRupees) + ' in rupees' + }} + +
+
+ + @if (data.caveats.length) { +
+ Check before filing. +
    + @for (caveat of data.caveats; track caveat) { +
  • {{ caveat }}
  • + } +
+
+ } + + + + + + + + + + + @for (line of data.liabilityLines; track line.configKey) { + + + + + + @for (account of line.accounts; track $index) { + + + + + } + } + + + + + + + + + +
A. Liabilities in IndiaRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + + @if (line.note) { + {{ line.note }} + } + {{ amount(line.amount) }}
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + @if (isPartial(account)) { + {{ account.weightPercentage }}% share + } + {{ amount(account.balance) }}
Total Demand and Time Liabilities (4 + 5 + 6){{ amount(total('totalDemandAndTime')) }}
TOTAL LIABILITIES{{ amount(total('totalLiabilities')) }}
+ + + + + + + + + + + @for (line of data.assetLines; track line.configKey) { + + + + + + @if (line.accountsMirrorHead && line.accounts.length) { + + + + } + @for (account of line.accounts; track $index) { + + + + + } + } + + + + + +
B. Assets in IndiaRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + + @if (line.note) { + {{ line.note }} + } + {{ amount(line.amount) }}
+ As the ledger holds them — these balances carry the opposite sign and add to the figure above +
+ {{ account.glCode }} + {{ account.name }} + @if (account.excluded) { + excluded — {{ account.excluded }} + } + @if (isPartial(account)) { + {{ account.weightPercentage }}% share + } + {{ amount(account.balance) }}
TOTAL ASSETS{{ amount(total('totalAssets')) }}
+ + + @if (data.memoLines.length) { + + + + + + + + + @for (line of data.memoLines; track line.configKey) { + + + + + } + + + + + + + + + +
Stated splits and explanations — entering no totalRs. in Thousands
+ {{ line.label }} + @if (!line.configured) { + Not mapped + } + {{ amount(line.amount) }}
Percentage of overdues to total loans and advances{{ percent(total('overduePercentage')) }}
Priority sector advances as a percentage of total loans{{ percent(total('prioritySectorPercentage')) }}
+ } + +
+
AUTHORISED SIGNATORY
+
+
+ } +
diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.scss b/src/app/rbi-circulars/form9-return/form9-return.component.scss new file mode 100644 index 000000000..3a3fd42e1 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-return.component.scss @@ -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; + } +} diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.ts b/src/app/rbi-circulars/form9-return/form9-return.component.ts new file mode 100644 index 000000000..01608f2f9 --- /dev/null +++ b/src/app/rbi-circulars/form9-return/form9-return.component.ts @@ -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; + 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 { + 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); + } +} diff --git a/src/app/rbi-circulars/oss-live/oss-return.base.ts b/src/app/rbi-circulars/oss-live/oss-return.base.ts index 3ed065606..f62312051 100644 --- a/src/app/rbi-circulars/oss-live/oss-return.base.ts +++ b/src/app/rbi-circulars/oss-live/oss-return.base.ts @@ -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 { diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts index 791827a72..650354f34 100644 --- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts +++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts @@ -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. ]) ]; diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts index 38fd0c260..ed6e10638 100644 --- a/src/app/rbi-circulars/rbi-circulars.service.ts +++ b/src/app/rbi-circulars/rbi-circulars.service.ts @@ -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 { + getForm1(reportingDate?: string, baseDate?: string, officeId?: number): Observable { 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 { + 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 { + 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 { + 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 ── /** diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts index c5935ff16..a28486f4e 100644 --- a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts +++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts @@ -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 { + return this.rbiService.getForm1Statutory( + this.statutoryDate, + this.statutoryFirstColumn || undefined, + this.statutorySecondColumn || undefined + ); + } protected fetch(reportingDate: string): Observable { - 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; + } } diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss new file mode 100644 index 000000000..65c05639d --- /dev/null +++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss @@ -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; + } +} diff --git a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts b/src/app/rbi-circulars/reserve-return/form9-slr.component.ts deleted file mode 100644 index 656b1fdc3..000000000 --- a/src/app/rbi-circulars/reserve-return/form9-slr.component.ts +++ /dev/null @@ -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 { - return this.rbiService.getForm9(reportingDate); - } - - ngOnInit(): void { - this.load(); - this.loadSaved(); - } -} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts index efa7f5962..3fdbea146 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts +++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts @@ -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 { + 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( + `${title}` + + `${sheet.outerHTML}` + + '' + + '' + ); + 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 { + 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' }; + } + } } diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 2f8dbc1a9..9495bf0d5 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -7,7 +7,88 @@ -->
- @if (loading) { + + + @if (hasStatutoryForm) { +
+ + +
+ } + + + @if (hasStatutoryForm) { + + } @else if (awaitingDate) { +
+

{{ promptTitle }}

+

+ 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. +

+ + +
+ @if (hasBaseDate) { + + Base date (optional) + + + } + + Reporting date + + + +
+ + @if (chosenFortnight) { +

+ Fortnight: {{ chosenFortnight }} + @if (hasBaseDate && !baseDate) { + · base will be derived from the 45-day lag + } +

+ } + + +
+ Jump to: + + + @if (hasBaseDate && baseDate) { + + } +
+ + @if (errorMsg) { +
{{ errorMsg }}
+ } +
+ } @else if (loading) {
Computing from the ledger…
} @else if (errorMsg) {
{{ errorMsg }}
@@ -27,11 +108,19 @@
+ + @if (hasBaseDate) { + + Base date + + + } Reporting date - + - + @@ -152,12 +245,20 @@
+ - - + + + @@ -175,10 +276,41 @@ Not configured } - - + + - - + + + + } } @@ -219,16 +379,19 @@ + + + } @@ -345,6 +508,204 @@ } } + + @if (activeTab === 'statutory') { + @if (statutoryAwaitingDate) { +
+

{{ statutoryTitle }}

+

{{ statutorySubtitle }}

+ + @if (statutoryTakesTwoColumns) { + +
+ + + First column + + + + Second column + + + +
+ + + @if (statutoryFirstColumn && statutorySecondColumn) { +

+ Made up to {{ statutorySecondColumn }}. Both ratios are charged on the last Friday of the second preceding fortnight, stated on the form + itself. +

+ } @else { +

Enter both fortnight closing dates to run the form.

+ } + + +
+ Fill: + + @if (statutoryFirstColumn || statutorySecondColumn) { + + } +
+ } @else { + +
+ + Reporting date + + + +
+ @if (statutoryDate) { +

+ The return reports the month ending on or before {{ statutoryDate }}, and charges the ratio on the last Friday of the second preceding fortnight. +

+ } + } + + @if (statutoryError) { +
{{ statutoryError }}
+ } +
+ } @else if (statutoryLoading) { +
Computing from the ledger…
+ } @else if (statutoryError) { +
{{ statutoryError }}
+ } @else if (statutory) { +
+ Made up to {{ statutory.reportingDate }} + + + + + +
+ + +
+
+
{{ statutory.formName }}
+
{{ statutory.statutoryReference }}
+
+ + @if (statutory.unmetRequirements.length) { +
+ Check before filing. +
    + @for (reason of statutory.unmetRequirements; track reason) { +
  • {{ reason }}
  • + } +
+
+ } + +
ParticularsAs atAmount (INR){{ data.baseDate || 'Base date' }}{{ data.currentDate || 'Reporting date' }}Movement
{{ line.asOnDate }} + + @if (line.configured) { - {{ amount(line.amount) }} + {{ + amount( + line.baseAmount !== undefined + ? line.baseAmount + : line.asOnDate === data.baseDate + ? line.amount + : null + ) + }} + } @else { + — + } + + @if (line.configured) { + {{ + amount( + line.currentAmount !== undefined + ? line.currentAmount + : line.asOnDate === data.currentDate + ? line.amount + : null + ) + }} + } @else { + — + } + + @if (line.movement !== undefined && line.movement !== null) { + {{ amount(line.movement) }} } @else { — } @@ -200,7 +332,10 @@ it this head takes, so the two can be reconciled without opening the ledger. --> @if (isPartial(account)) { - {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + {{ account.weightPercentage }}% of {{ portionOf(account) }} } @@ -208,8 +343,33 @@ {{ account.liabilityClass | titlecase }} } {{ amount(account.balance) }} + @if ( + account.baseBalance !== undefined ? account.baseBalance !== null : line.asOnDate === data.baseDate + ) { + {{ amount(account.baseBalance !== undefined ? account.baseBalance : account.balance) }} + } @else { + — + } + + @if ( + account.currentBalance !== undefined + ? account.currentBalance !== null + : line.asOnDate === data.currentDate + ) { + {{ amount(account.currentBalance !== undefined ? account.currentBalance : account.balance) }} + } @else { + — + } + + @if (account.movement !== undefined && account.movement !== null) { + {{ amount(account.movement) }} + } +
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL) {{ amount(data.requiredAmount) }}
{{ data.rateType }} maintained {{ amount(data.maintainedAmount) }}
{{ isShortfall ? 'Shortfall' : 'Surplus' }} {{ amount(shortfallAmount) }}
+ + + + + @for (col of statutory.columns; track col; let i = $index) { + + } + + + + @for (part of statutoryParts; track part.title) { + + + + @for (line of part.lines; track line.label) { + @if (line.heading) { + + + + } @else { + + + @for (v of line.values; track $index) { + + } + + + @if (line.baseDates?.length) { + + + @for (d of line.baseDates; track $index) { + + } + + } + } + } + @if (part.surplus?.length) { + + + @for (s of part.surplus; track $index) { + + } + + } + } + +
Particulars + {{ col }} + {{ i === 2 ? 'base for ratios' : 'fortnight close' }} +
{{ part.title }}
{{ line.label }}
+ {{ line.label }} + @if (line.configKey && line.configured === false) { + Not mapped + } + {{ amount(v) }}
Base dates — last Friday of the second preceding fortnight{{ d }}
Surplus / (Shortfall){{ amount(s) }}
+ +
+
AUTHORISED SIGNATORY
+
+
+ } + } + + @if (canSave) {
Note (optional) @@ -452,101 +816,149 @@
-
-
- NDTL base - {{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }} - as at {{ savedData.basis.dtlAsOnDate }} -
-
- {{ savedData.rateType }} rate - {{ savedData.ratePercentage }}% - as filed -
-
- Required - {{ amount(savedData.requiredAmount) }} - {{ savedData.ratePercentage }}% of NDTL -
-
- Maintained - {{ amount(savedData.maintainedAmount) }} - on {{ savedData.basis.holdingsAsOnDate }} -
-
- {{ savedData.compliant ? 'Surplus' : 'Shortfall' }} - {{ amount(savedSurplus) }} - {{ savedData.compliant ? 'Above requirement' : 'Below requirement' }} -
-
- - - - - - - - - - - @for (line of savedData.lines; track line.configKey) { - - - - + @for (part of savedStatutoryParts; track part.title) { + + + + @for (line of part.lines; track line.label) { + + + @for (value of line.values || []; track $index) { + + } + + } + } + +
ParticularsAs atAmount (INR)
- {{ line.label }} - {{ line.asOnDate }} - @if (line.configured) { - {{ amount(line.amount) }} - } @else { - — + + @if (savedIsStatutory && savedStatutory; as saved) { +
+
+
{{ saved.formName }}
+
{{ saved.statutoryReference }}
+
+ + + + + @for (col of saved.columns; track col; let i = $index) { + } - - - @for (account of line.accounts; track account.glAccountId) { - - - - - } - } - -
Particulars + {{ col }} + {{ i === 2 ? 'base for ratios' : 'fortnight close' }} +
- {{ account.glCode }} - {{ account.name }} - @if (isPartial(account)) { - - {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} - - } - @if (account.liabilityClass) { - {{ account.liabilityClass | titlecase }} - } - {{ amount(account.balance) }}
+ +
{{ part.title }}
+ {{ line.label }} + {{ amount(value) }}
+
+ } @else { +
+
+ NDTL base + {{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }} + as at {{ savedData.basis.dtlAsOnDate }} +
+
+ {{ savedData.rateType }} rate + {{ savedData.ratePercentage }}% + as filed +
+
+ Required + {{ amount(savedData.requiredAmount) }} + {{ savedData.ratePercentage }}% of NDTL +
+
+ Maintained + {{ amount(savedData.maintainedAmount) }} + on {{ savedData.basis.holdingsAsOnDate }} +
+
+ {{ savedData.compliant ? 'Surplus' : 'Shortfall' }} + {{ amount(savedSurplus) }} + {{ savedData.compliant ? 'Above requirement' : 'Below requirement' }} +
+
- @if (savedData.maintenance; as sm) { -

Daily maintenance as filed — {{ sm.periodLabel || '' }}

- - - - + + + - @for (day of sm.days; track day.date) { - - - - - + @for (line of savedData.lines; track line.configKey) { + + + + + @for (account of line.accounts; track account.glAccountId) { + + + + + + } }
DateRequiredActualSurplus / (Shortfall)ParticularsAs atAmount (INR)
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }}
+ {{ line.label }} + {{ line.asOnDate }} + @if (line.configured) { + {{ amount(line.amount) }} + } @else { + — + } +
+ {{ account.glCode }} + {{ account.name }} + @if (isPartial(account)) { + + {{ account.weightPercentage }}% of {{ amount(account.accountBalance) }} + + } + @if (account.liabilityClass) { + {{ account.liabilityClass | titlecase }} + } + {{ amount(account.balance) }}
+ + @if (savedData.maintenance; as sm) { +

Daily maintenance as filed — {{ sm.periodLabel || '' }}

+ + + + + + + + + + + @for (day of sm.days; track day.date) { + + + + + + + } + +
DateRequiredActualSurplus / (Shortfall)
{{ day.date }}{{ amount(day.required) }}{{ amount(day.amount) }}{{ amount(day.surplus) }}
+ } }
} diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss index cdc132990..6a550b856 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.scss +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.scss @@ -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 { diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index a7db00b1d..af43e1032 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -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' }, diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 6a375a6e4..29388afe4 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260928', - 'hash': '6cbb31b5' + 'version': '261001', + 'hash': '4f02f24a' }, 'allow_switching_backend_instance': true };