/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { SettingsService } from 'app/settings/settings.service'; import { exportWorkbook } from '../cashier/cashier-export'; import { InterestPostingDue, InterestPostingReport, InterestPostingReportDetail, InterestPostingReportService, InterestPreviewRow, ReportScope } from './interest-posting-report.service'; /** * Savings interest posting reports. * * On a product's posting date the system generates, at noon, the list of what each savings account * will be paid. It is reviewed and approved here; the approved amounts are posted when the last * branch closes the day, and until then no branch can close. Regenerating replaces the report and * needs approving again. */ @Component({ selector: 'mifosx-interest-posting-report', templateUrl: './interest-posting-report.component.html', styleUrls: ['./interest-posting-report.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, FormsModule, FaIconComponent ] }) export class InterestPostingReportComponent implements OnInit { private reportService = inject(InterestPostingReportService); private settingsService = inject(SettingsService); postingDate = ''; loading = false; busy = false; errorMessage = ''; notice = ''; reports: InterestPostingReport[] = []; selected: InterestPostingReportDetail | null = null; /** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */ due: InterestPostingDue[] = []; /** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */ tab: ReportScope = 'own'; isHeadOffice = false; myBranch = ''; ngOnInit(): void { this.postingDate = this.businessDateIso(); this.reportService.context().subscribe({ next: (ctx) => { this.isHeadOffice = !!ctx?.headOffice; this.myBranch = ctx?.officeName ?? ''; }, error: () => (this.isHeadOffice = false) }); this.load(); } switchTab(tab: ReportScope): void { if (this.tab === tab) { return; } this.tab = tab; this.selected = null; this.notice = ''; this.load(); } /** Acting for another branch is the head office's exception, so it is spelt out before it happens. */ private confirmOnBehalf(officeName: string, action: string): boolean { if (this.tab !== 'others') { return true; } return confirm( `${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` + 'from the head office. Continue?' ); } load(): void { this.loading = true; this.errorMessage = ''; this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({ next: (reports) => { this.reports = reports ?? []; this.loading = false; const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id); if (stillThere) { this.open(stillThere); } else { this.selected = null; } }, error: (err) => { this.loading = false; this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.'); } }); this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({ next: (due) => (this.due = due ?? []), error: () => (this.due = []) }); } generate(item: InterestPostingDue): void { if (!this.confirmOnBehalf(item.officeName, 'generate')) { return; } this.busy = true; this.notice = ''; this.errorMessage = ''; this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({ next: (res) => { this.busy = false; this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`; this.load(); this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); }, error: (err) => { this.busy = false; this.errorMessage = this.messageOf(err, 'Could not generate the report.'); } }); } open(report: InterestPostingReport): void { this.errorMessage = ''; this.reportService.detail(report.id).subscribe({ next: (detail) => (this.selected = detail), error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.')) }); } regenerate(report: InterestPostingReport): void { if ( !confirm( `Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.` ) || !this.confirmOnBehalf(report.officeName, 'regenerate') ) { return; } this.busy = true; this.notice = ''; this.errorMessage = ''; this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({ next: (res) => { this.busy = false; this.notice = `Report regenerated for ${report.productName}. Review and approve it.`; this.selected = null; this.load(); this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail)); }, error: (err) => { this.busy = false; this.errorMessage = this.messageOf(err, 'Could not regenerate the report.'); } }); } approve(report: InterestPostingReport): void { if ( !confirm( `Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` + 'It will be posted when the branch closes the day.' ) || !this.confirmOnBehalf(report.officeName, 'approve') ) { return; } this.busy = true; this.notice = ''; this.errorMessage = ''; this.reportService.approve(report.id).subscribe({ next: () => { this.busy = false; this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`; this.load(); }, error: (err) => { this.busy = false; this.errorMessage = this.messageOf(err, 'Could not approve the report.'); } }); } canRegenerate(report: InterestPostingReport): boolean { return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED'); } canApprove(report: InterestPostingReport): boolean { return report.canAct && report.status === 'GENERATED'; } statusClass(status: string): string { return ( { GENERATED: 'ipr-badge--pending', APPROVED: 'ipr-badge--approved', POSTED: 'ipr-badge--posted', SUPERSEDED: 'ipr-badge--muted' }[status] ?? 'ipr-badge--muted' ); } /** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */ get pendingCount(): number { return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0; } // ── Preview: what each account has earned so far, as if posted today. View only. ── showPreview = false; previewLoading = false; previewRows: InterestPreviewRow[] = []; previewAsOf = ''; openPreview(): void { this.showPreview = true; this.previewLoading = true; this.errorMessage = ''; this.reportService.preview().subscribe({ next: (rows) => { this.previewRows = rows ?? []; this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso(); this.previewLoading = false; }, error: (err) => { this.previewLoading = false; this.previewRows = []; this.errorMessage = this.messageOf(err, 'Could not load the interest preview.'); } }); } closePreview(): void { this.showPreview = false; } get previewTotal(): number { return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0); } /** Totals by branch and product, for the summary above the account list and the export. */ get previewGroups(): { officeName: string; productName: string; postingDate: string; accounts: number; interest: number; }[] { const map = new Map< string, { officeName: string; productName: string; postingDate: string; accounts: number; interest: number } >(); this.previewRows.forEach((r) => { const key = `${r.officeName}|${r.productName}`; const g = map.get(key) ?? { officeName: r.officeName, productName: r.productName, postingDate: r.postingDate, accounts: 0, interest: 0 }; g.accounts += 1; g.interest += Number(r.interest || 0); map.set(key, g); }); return [...map.values()]; } exportPreview(): void { const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`; exportWorkbook( [ { sheetName: 'By account', title: 'Savings Interest Preview', subtitle: stamp, headers: [ 'Branch', 'Product', 'Account No', 'Customer', 'Balance', 'Rate %', 'Cycle Start', 'Posting Date', 'Interest So Far' ], widths: [ 18, 22, 20, 26, 16, 8, 12, 12, 16 ], rows: [ ...this.previewRows.map((r) => [ r.officeName, r.productName, r.accountNo, r.clientName, Number(r.balance || 0), Number(r.rate || 0), r.cycleStart, r.postingDate, Number(r.interest || 0) ]), [ 'TOTAL', '', '', '', '', '', '', '', this.previewTotal ] ] }, { sheetName: 'By branch & product', title: 'Savings Interest Preview — Totals', subtitle: stamp, headers: [ 'Branch', 'Product', 'Posting Date', 'Accounts', 'Interest So Far' ], widths: [ 18, 22, 12, 10, 16 ], rows: [ ...this.previewGroups.map((g) => [ g.officeName, g.productName, g.postingDate, g.accounts, g.interest ]), [ 'TOTAL', '', '', this.previewRows.length, this.previewTotal ] ] } ], `savings-interest-preview-${this.previewAsOf}` ); } private businessDateIso(): string { const businessDate = this.settingsService.businessDate as any; const parsed = businessDate ? new Date(businessDate) : new Date(); if (isNaN(parsed.getTime())) { return new Date().toISOString().slice(0, 10); } const month = String(parsed.getMonth() + 1).padStart(2, '0'); const day = String(parsed.getDate()).padStart(2, '0'); return `${parsed.getFullYear()}-${month}-${day}`; } private messageOf(err: any, fallback: string): string { return ( err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback ); } }