Files
cbsnexora/src/app/interest-posting-summary/interest-posting-summary.component.ts
T
keshavandClaude Opus 5.5 b6994c0b76 feat(rbi-reserve,savings): Form 1/Form 9 returns UI, branch-wise interest posting reports and summary
- Reports > Interest Posting Reports: per branch and product, generate/regenerate
  at any time, approve; branch column, HO-on-behalf marker, posted txn per account
- Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export
- Day Close warns and disables Close Day while the branch's interest report is unapproved
- Savings product accounting: Interest On Savings (expense), Savings Interest Payable
  and Paid (liability rows, required)
- RBI Form 1 statutory and Form 9 return screens, reserve config updates
- Interest posting report GETs exempted from the response cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:20:01 +05:30

245 lines
7.3 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingReportService,
InterestPostingSummaryRow
} from '../interest-posting-report/interest-posting-report.service';
/**
* The head office's view of savings interest across branches: for each posting date, branch and
* product, how many customers were paid and how much, and whether it has been approved and posted.
* Nothing is approved here — each branch approves its own — so it is a report and an Excel export.
*/
@Component({
selector: 'mifosx-interest-posting-summary',
templateUrl: './interest-posting-summary.component.html',
styleUrls: ['../interest-posting-report/interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingSummaryComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
fromDate = '';
toDate = '';
productId: number | null = null;
loading = false;
errorMessage = '';
rows: InterestPostingSummaryRow[] = [];
ngOnInit(): void {
const today = this.businessDateIso();
this.toDate = today;
this.fromDate = `${today.slice(0, 7)}-01`;
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.summary(this.fromDate || undefined, this.toDate || undefined).subscribe({
next: (rows) => {
this.rows = rows ?? [];
this.loading = false;
},
error: (err) => {
this.loading = false;
this.rows = [];
this.errorMessage =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.message ??
'Could not load the interest summary.';
}
});
}
/** Products present in the loaded rows, for the filter. */
get products(): { id: number; name: string }[] {
const seen = new Map<number, string>();
this.rows.forEach((r) => seen.set(r.productId, r.productName));
return [...seen.entries()].map(
([
id,
name
]) => ({ id, name })
);
}
get shown(): InterestPostingSummaryRow[] {
return this.productId ? this.rows.filter((r) => r.productId === this.productId) : this.rows;
}
get totalCustomers(): number {
return this.shown.reduce((sum, r) => sum + (r.accountCount || 0), 0);
}
get totalInterest(): number {
return this.shown.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get postedInterest(): number {
return this.shown.filter((r) => r.status === 'POSTED').reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get pendingCount(): number {
return this.shown.filter((r) => r.status !== 'POSTED').length;
}
/** Branch totals, for the second table and the export. */
get byBranch(): { officeName: string; customers: number; interest: number; posted: number; reports: number }[] {
const map = new Map<
string,
{ officeName: string; customers: number; interest: number; posted: number; reports: number }
>();
this.shown.forEach((r) => {
const key = r.officeName || '—';
const row = map.get(key) ?? { officeName: key, customers: 0, interest: 0, posted: 0, reports: 0 };
row.customers += r.accountCount || 0;
row.interest += Number(r.totalInterest || 0);
row.posted += r.status === 'POSTED' ? Number(r.totalInterest || 0) : 0;
row.reports += 1;
map.set(key, row);
});
return [...map.values()].sort((a, b) => a.officeName.localeCompare(b.officeName));
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted'
}[status] ?? 'ipr-badge--muted'
);
}
exportExcel(): void {
const period = `Posting dates ${this.fromDate || '…'} to ${this.toDate || '…'}`;
const product = this.productId ? this.products.find((p) => p.id === this.productId)?.name : 'All products';
exportWorkbook(
[
{
sheetName: 'By report',
title: 'Savings Interest — Branch-wise Posting',
subtitle: `${period} · ${product}`,
headers: [
'Posting Date',
'Branch',
'Product',
'Customers',
'Interest',
'Status',
'Approved By',
'Approved At',
'Posted At'
],
widths: [
14,
22,
24,
12,
16,
12,
20,
20,
20
],
rows: [
...this.shown.map((r) => [
r.postingDate,
r.officeName,
r.productName,
r.accountCount,
Number(r.totalInterest || 0),
r.status,
(r.approvedBy || '') + (r.approvedOnBehalf ? ' (HO)' : ''),
r.approvedAt || '',
r.postedAt || ''
]),
[
'TOTAL',
'',
'',
this.totalCustomers,
this.totalInterest,
'',
'',
'',
''
]
]
},
{
sheetName: 'By branch',
title: 'Savings Interest — Branch Totals',
subtitle: `${period} · ${product}`,
headers: [
'Branch',
'Reports',
'Customers',
'Interest',
'Posted',
'Not Yet Posted'
],
widths: [
22,
10,
12,
16,
16,
16
],
rows: [
...this.byBranch.map((b) => [
b.officeName,
b.reports,
b.customers,
b.interest,
b.posted,
b.interest - b.posted
]),
[
'TOTAL',
this.shown.length,
this.totalCustomers,
this.totalInterest,
this.postedInterest,
this.totalInterest - this.postedInterest
]
]
}
],
`savings-interest-branch-summary-${this.fromDate}-to-${this.toDate}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
}