- 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>
245 lines
7.3 KiB
TypeScript
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}`;
|
|
}
|
|
}
|