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