feat: cash-only registers, day book sections and DD amendments
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
@@ -0,0 +1,205 @@
<!--
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/.
-->
<div class="ir">
<!-- Header -->
<div class="ir-head">
<div>
<h2 class="ir-head-t"><fa-icon icon="percentage"></fa-icon> Savings Interest Posted</h2>
<div class="ir-head-s">
Interest actually credited to members, grouped by the rhythm each product posts on. An account appears under its
own product's posting period and nowhere else.
</div>
</div>
<div class="ir-head-a">
<button class="ir-btn ir-btn--slim" (click)="load()" [disabled]="loading">
<fa-icon icon="undo"></fa-icon> Reload
</button>
<button class="ir-btn ir-btn--primary" (click)="exportExcel()" [disabled]="loading">
<fa-icon icon="file-excel"></fa-icon> Export all periods
</button>
</div>
</div>
@if (errorMessage) {
<div class="ir-alert"><fa-icon icon="exclamation-triangle"></fa-icon> {{ errorMessage }}</div>
}
<!--
A product paid on more dates than its rhythm allows was paid by something other than this
ledger — stock Fineract's savings interest job writes backdated postings a day at a time and
ignores the product's posting period. The postings are real and stay on the report; this says
so, so a total is not read as the rhythm the product is meant to keep.
-->
@if (rhythmWarnings.length > 0) {
<div class="ir-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Postings do not follow the product's rhythm.</strong>
@for (w of rhythmWarnings; track w.product) {
<div class="ir-warn-row">
<strong>{{ w.product }}</strong> is configured {{ w.label }} ({{ w.expected }}) but has paid on
<strong>{{ w.dates }} different dates</strong>.
</div>
}
<div class="ir-warn-note">
These were written by the platform's own savings interest job, which posts a day at a time regardless of the
product's period. Every posting below is real money credited to a member — the figures are correct, but a
period total will not match the rhythm the product is set to.
</div>
</div>
</div>
}
<!-- Filters, shared by every period -->
<div class="ir-filters">
<div class="ir-field">
<label>From</label>
<input class="ir-inp" type="date" [(ngModel)]="fromDate" (ngModelChange)="load()" />
</div>
<div class="ir-field">
<label>To</label>
<input class="ir-inp" type="date" [(ngModel)]="toDate" (ngModelChange)="load()" />
</div>
<div class="ir-field ir-field--wide">
<label>Search</label>
<input class="ir-inp" type="text" [(ngModel)]="search" placeholder="Account number, customer or product" />
</div>
<div class="ir-field ir-field--btn">
<button class="ir-btn ir-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
</div>
<!-- One card per posting rhythm; each is its own report -->
<div class="ir-tabs">
@for (p of periods; track p.enumValue) {
<button class="ir-tab" [class.ir-tab--on]="activePeriod === p.enumValue" (click)="showPeriod(p.enumValue)">
<div class="ir-tab-h">
<span class="ir-tab-l">{{ p.label }}</span>
<span class="ir-tab-n">{{ countFor(p.enumValue) }}</span>
</div>
<div class="ir-tab-d">{{ p.cadence }}</div>
<div class="ir-tab-t">₹{{ totalFor(p.enumValue) | number: '1.2-2' }}</div>
</button>
}
</div>
<!--
The period control for the rhythm in view.
Each rhythm gets its own, at its own granularity: a day for Daily, a month for the rest. One
shared control would make the quarterly report filter by a single date and hide the rest of the
quarter.
-->
<div class="ir-pick">
<label class="ir-pick-l">{{ activeMeta.pickLabel }}</label>
<!--
A dropdown of the dates this rhythm actually posted on, not a calendar: a quarterly product
posts four times a year, and a date picker would offer every other day of it holding nothing.
The account count rides in the option, so "how many accounts at this quarter end" is answered
before the filter is applied.
-->
<select class="ir-inp ir-inp--pick" [(ngModel)]="periodPick[activePeriod]">
<option value="">All {{ activeMeta.pickUnit }}s</option>
@for (p of availablePicks(activePeriod); track p.date) {
<option [value]="p.date">
{{ formatDate(p.date) }} — {{ p.accounts }} account{{ p.accounts === 1 ? '' : 's' }}, ₹{{
p.total | number: '1.2-2'
}}
</option>
}
</select>
@if (periodPick[activePeriod]) {
<button class="ir-btn ir-btn--slim" (click)="clearPick(activePeriod)">
<fa-icon icon="undo"></fa-icon> Show all
</button>
}
<span class="ir-spacer"></span>
<span class="ir-pick-hint">
{{ availablePicks(activePeriod).length }} {{ activeMeta.pickUnit
}}{{ availablePicks(activePeriod).length === 1 ? '' : 's' }} with postings
</span>
</div>
<!-- What the tab in view holds -->
<div class="ir-context">
<div class="ir-context-i">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>{{ activeMeta.label }}.</strong> {{ activeMeta.cadence }} — every account on a product configured to
post {{ activeMeta.label.toLowerCase() }}.
</div>
</div>
<div class="ir-context-t">
<span class="ir-context-c">{{ visibleAccounts }}</span> accounts ·
<span class="ir-context-c">{{ visibleRows.length }}</span> postings ·
<span class="ir-context-c">₹{{ visibleTotal | number: '1.2-2' }}</span> paid
</div>
</div>
<!-- Postings, grouped by the day they were paid -->
@if (loading) {
<div class="ir-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
} @else if (visibleRows.length === 0) {
<div class="ir-empty">
No {{ activeMeta.label.toLowerCase() }} interest posted in this period.
@if (activePeriod !== 1) {
A cycle that has not ended yet has nothing to show.
}
</div>
} @else {
@for (group of groupedByDate; track group.date) {
<div class="ir-group">
<div class="ir-group-h">
<span class="ir-group-d"><fa-icon icon="calendar"></fa-icon> {{ formatDate(group.date) }}</span>
<span class="ir-group-m">
{{ group.accounts }} account{{ group.accounts === 1 ? '' : 's' }} ·
<strong>₹{{ group.total | number: '1.2-2' }}</strong>
</span>
</div>
<div class="ir-tablewrap">
<table class="ir-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Product</th>
<th class="num">Rate</th>
<th class="num">Balance Before</th>
<th class="num">Interest Posted</th>
<th class="num">Balance After</th>
<th>Branch</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of group.rows; track r.transaction_id) {
<tr>
<td class="mono">{{ r.account_no }}</td>
<td>{{ r.client_name || '—' }}</td>
<td class="ir-prod">{{ r.product_name }}</td>
<td class="num">{{ r.interest_rate | number: '1.2-2' }}%</td>
<td class="num">{{ r.balance_before | number: '1.2-2' }}</td>
<td class="num strong ir-amt">{{ r.interest_amount | number: '1.2-2' }}</td>
<td class="num">{{ r.balance_after | number: '1.2-2' }}</td>
<td>{{ r.office_name || '—' }}</td>
<td>
<button class="ir-mini" (click)="openAccount(r)" [disabled]="r.client_id == null">
<fa-icon icon="list"></fa-icon> Transactions
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
@@ -0,0 +1,470 @@
/**
* 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-g50: #f8fafc;
--i-g100: #f1f5f9;
--i-g200: #e2e8f0;
--i-g300: #cbd5e1;
--i-g500: #64748b;
--i-g700: #334155;
--i-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--i-g900);
background: var(--i-g50);
min-height: 100%;
padding: 16px;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.strong {
font-weight: 700;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.ir-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.ir-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--i-navy);
}
.ir-head-s {
margin-top: 3px;
font-size: 12px;
color: var(--i-g500);
max-width: 760px;
line-height: 1.5;
}
.ir-head-a {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.ir-alert {
padding: 10px 14px;
margin-bottom: 12px;
background: var(--i-red-l);
border: 1px solid #f5b7b1;
border-radius: 7px;
font-size: 12px;
color: var(--i-red);
}
/* ── Filters ────────────────────────────────────────────────────────────── */
.ir-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 13px 14px;
margin-bottom: 14px;
background: #fff;
border: 1px solid var(--i-g200);
border-radius: 7px;
}
.ir-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 150px;
label {
font-size: 10.5px;
font-weight: 700;
color: var(--i-g500);
text-transform: uppercase;
letter-spacing: 0.3px;
}
&--wide {
flex: 1;
min-width: 240px;
}
&--btn {
min-width: 0;
}
}
.ir-inp {
padding: 7px 10px;
border: 1px solid var(--i-g300);
border-radius: 5px;
font: inherit;
font-size: 12px;
background: #fff;
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.ir-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 13px;
border: 1px solid var(--i-g300);
border-radius: 5px;
background: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--i-g700);
cursor: pointer;
&:hover:not(:disabled) {
border-color: var(--i-blue);
color: var(--i-blue);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--slim {
padding: 6px 11px;
font-size: 11.5px;
}
&--primary {
background: var(--i-blue);
border-color: var(--i-blue);
color: #fff;
&:hover:not(:disabled) {
background: #1e40af;
color: #fff;
}
}
}
.ir-mini {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border: 1px solid var(--i-g300);
border-radius: 5px;
background: #fff;
font: inherit;
font-size: 10.5px;
font-weight: 600;
color: var(--i-g700);
cursor: pointer;
&:hover:not(:disabled) {
border-color: var(--i-blue);
color: var(--i-blue);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
/* ── One card per posting rhythm ────────────────────────────────────────── */
.ir-tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.ir-tab {
text-align: left;
padding: 11px 13px;
border: 1px solid var(--i-g200);
border-top: 3px solid var(--i-g300);
border-radius: 7px;
background: #fff;
font: inherit;
cursor: pointer;
transition: box-shadow 0.12s;
&:hover {
box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
}
/* The rhythm on display stays obvious while its rows are being read. */
&--on {
background: var(--i-blue-l);
border-top-color: var(--i-blue);
box-shadow: inset 0 0 0 1px #bfdbfe;
}
}
.ir-tab-h {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ir-tab-l {
font-size: 13px;
font-weight: 700;
}
.ir-tab-n {
font-size: 19px;
font-weight: 700;
line-height: 1;
}
.ir-tab-d {
margin-top: 2px;
font-size: 10px;
color: var(--i-g500);
}
.ir-tab-t {
margin-top: 6px;
font-size: 12px;
font-weight: 700;
color: var(--i-green);
}
/* ── Context strip ──────────────────────────────────────────────────────── */
.ir-context {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
margin-bottom: 12px;
background: var(--i-blue-l);
border: 1px solid #bfdbfe;
border-radius: 7px;
font-size: 11.5px;
line-height: 1.55;
color: var(--i-g700);
}
.ir-context-i {
display: flex;
gap: 9px;
fa-icon {
color: var(--i-blue);
margin-top: 2px;
}
}
.ir-context-t {
white-space: nowrap;
color: var(--i-g500);
}
.ir-context-c {
font-weight: 700;
color: var(--i-g900);
}
/* ── One group per posting date ─────────────────────────────────────────── */
.ir-group {
margin-bottom: 14px;
background: #fff;
border: 1px solid var(--i-g200);
border-radius: 7px;
overflow: hidden;
}
/* The date is the unit an officer reconciles against: a run pays a whole product on one day. */
.ir-group-h {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 14px;
background: var(--i-g100);
border-bottom: 1px solid var(--i-g200);
}
.ir-group-d {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 700;
color: var(--i-navy);
}
.ir-group-m {
font-size: 11.5px;
color: var(--i-g500);
strong {
color: var(--i-green);
}
}
/* ── Table ──────────────────────────────────────────────────────────────── */
.ir-tablewrap {
overflow-x: auto;
}
.ir-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--i-g200);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--i-g500);
white-space: nowrap;
&.num {
text-align: right;
}
}
td {
padding: 8px 10px;
border-bottom: 1px solid var(--i-g100);
}
tbody tr:hover {
background: var(--i-g50);
}
tbody tr:last-child td {
border-bottom: 0;
}
}
.ir-prod {
max-width: 210px;
font-size: 11.5px;
}
/* The figure the report exists for. */
.ir-amt {
color: var(--i-green);
}
.ir-empty {
padding: 34px;
text-align: center;
font-size: 12.5px;
color: var(--i-g500);
background: #fff;
border: 1px solid var(--i-g200);
border-radius: 7px;
}
/* ── Per-rhythm period control ──────────────────────────────────────────── */
.ir-pick {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 9px 14px;
margin-bottom: 10px;
background: #fff;
border: 1px solid var(--i-g200);
border-radius: 7px;
}
.ir-pick-l {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--i-g500);
}
.ir-spacer {
flex: 1;
}
.ir-pick-hint {
font-size: 11px;
color: var(--i-g500);
}
/* The dropdown carries a date, a count and a total, so it needs room. */
.ir-inp--pick {
min-width: 20rem;
}
/* ── Rhythm mismatch warning ────────────────────────────────────────────── */
.ir-warn {
display: flex;
gap: 10px;
padding: 11px 14px;
margin-bottom: 12px;
background: var(--i-amber-l);
border: 1px solid #fcd34d;
border-left: 4px solid var(--i-amber);
border-radius: 7px;
font-size: 12px;
line-height: 1.55;
color: var(--i-g700);
fa-icon {
color: var(--i-amber);
margin-top: 2px;
}
}
.ir-warn-row {
margin-top: 4px;
}
.ir-warn-note {
margin-top: 6px;
font-size: 11px;
color: var(--i-g500);
}
@@ -0,0 +1,445 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import {
SavingsDailyInterestService,
SavingsInterestPostingRow
} from '../savings-daily-interest/savings-daily-interest.service';
/** Shared report helpers */
import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export';
/**
* One posting rhythm, as the product configures it.
*
* The enum values are the product's own {@code interest_posting_period_enum}; they are not
* consecutive, and 2 and 3 are unused in this platform.
*/
interface PostingPeriod {
enumValue: number;
key: string;
label: string;
/** What "the end of the cycle" means for this rhythm, in the officer's words. */
cadence: string;
/** What the period control is asking for, in the terms this rhythm is read in. */
pickLabel: string;
/** The unit the control counts in, for the "N with postings" hint. */
pickUnit: string;
}
@Component({
selector: 'mifosx-interest-report',
templateUrl: './interest-report.component.html',
styleUrls: ['./interest-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestReportComponent implements OnInit {
private service = inject(SavingsDailyInterestService);
private router = inject(Router);
/**
* Each rhythm is its own report.
*
* A monthly product's month-end posting and a quarterly product's quarter-end posting are
* different events on different dates; listing them together reads as one uneven series and
* makes a month's total impossible to state. So an account appears under the rhythm its own
* product posts on, and nowhere else.
*/
readonly periods: PostingPeriod[] = [
{
enumValue: 1,
key: 'daily',
label: 'Daily',
cadence: 'Posted every day',
pickLabel: 'Posted on',
pickUnit: 'day'
},
{
enumValue: 4,
key: 'monthly',
label: 'Monthly',
cadence: 'Posted at each month end',
pickLabel: 'Month end',
pickUnit: 'month end'
},
{
enumValue: 5,
key: 'quarterly',
label: 'Quarterly',
cadence: 'Posted at each quarter end',
pickLabel: 'Quarter end',
pickUnit: 'quarter end'
},
{
enumValue: 6,
key: 'halfYearly',
label: 'Half-yearly',
cadence: 'Posted every six months',
pickLabel: 'Half-year end',
pickUnit: 'half-year end'
},
{
enumValue: 7,
key: 'yearly',
label: 'Yearly',
cadence: 'Posted at each year end',
pickLabel: 'Year end',
pickUnit: 'year end'
}
];
activePeriod = 4;
/** Every posting, across all rhythms — the tabs read from this. */
rows: SavingsInterestPostingRow[] = [];
loading = false;
errorMessage = '';
/** Filters, shared by every rhythm. */
fromDate = '';
toDate = '';
search = '';
ngOnInit(): void {
this.load();
}
/**
* Loads every posting once and splits it by rhythm in the browser.
*
* One request rather than five: the tab counts have to be right before a tab is opened, and
* asking per rhythm would leave them blank until each was visited.
*/
load(): void {
this.loading = true;
this.errorMessage = '';
this.service
.getPostings({
fromDate: this.fromDate || undefined,
toDate: this.toDate || undefined
})
.subscribe({
next: (data) => {
this.rows = data ?? [];
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the interest postings.';
}
});
}
// ── Per-rhythm views ───────────────────────────────────────────────────────
/**
* The period each rhythm is narrowed to, keyed by its enum.
*
* Each rhythm carries its own, because the unit an officer picks differs: a daily report is read
* one day at a time, a monthly one a month at a time. A single shared control would force the
* quarterly report to be filtered by a day, which selects one posting date and hides the rest of
* the quarter.
*/
periodPick: Record<number, string> = {};
private matchesPick(row: SavingsInterestPostingRow, periodEnum: number): boolean {
const pick = this.periodPick[periodEnum];
return !pick || row.posting_date === pick;
}
/**
* The cycle ends a rhythm actually posted on, each with how many accounts were paid.
*
* A dropdown of real posting dates rather than a free date picker: a rhythm posts on a handful of
* dates a year, and a calendar would offer three hundred and sixty-odd days that hold nothing.
* The account count sits in the option itself, so the answer to "how many accounts were paid at
* this quarter end" is visible before the filter is even applied.
*/
availablePicks(periodEnum: number): { date: string; accounts: number; total: number }[] {
const byDate = new Map<string, SavingsInterestPostingRow[]>();
for (const row of this.rows) {
if (row.posting_period_enum !== periodEnum || !row.posting_date) {
continue;
}
const list = byDate.get(row.posting_date);
if (list) {
list.push(row);
} else {
byDate.set(row.posting_date, [row]);
}
}
return [...byDate.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(
([
date,
rows
]) => ({
date,
accounts: new Set(rows.map((r) => r.savings_account_id)).size,
total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0)
})
);
}
clearPick(periodEnum: number): void {
delete this.periodPick[periodEnum];
}
/**
* Products whose postings do not follow the rhythm they are configured for.
*
* A quarterly product posts four times a year. Where one shows a hundred and thirty-five posting
* dates, something other than this ledger has paid it — stock Fineract's own savings interest job
* writes backdated postings a day at a time and pays no attention to the product's posting
* period. The report cannot hide those: the money reached the members and the transactions are
* real. What it can do is say so, so a total read off this screen is not mistaken for the rhythm
* the product is meant to keep.
*
* The threshold is generous — a rhythm is only called out at more than double the dates it could
* legitimately produce across the range on display, so a backdated correction or a re-run does
* not raise it.
*/
get rhythmWarnings(): { product: string; label: string; dates: number; expected: string }[] {
const expectedMax: Record<number, number> = { 1: 100000, 4: 24, 5: 8, 6: 4, 7: 2 };
const expectedWord: Record<number, string> = {
1: 'daily',
4: 'about one a month',
5: 'about four a year',
6: 'two a year',
7: 'one a year'
};
const byProduct = new Map<string, { label: string; period: number; dates: Set<string> }>();
for (const row of this.rows) {
const entry = byProduct.get(row.product_name);
if (entry) {
entry.dates.add(row.posting_date);
} else {
byProduct.set(row.product_name, {
label: this.periods.find((p) => p.enumValue === row.posting_period_enum)?.label ?? '—',
period: row.posting_period_enum,
dates: new Set([row.posting_date])
});
}
}
return [...byProduct.entries()]
.filter(
([
,
v
]) => v.dates.size > (expectedMax[v.period] ?? 100000)
)
.map(
([
product,
v
]) => ({
product,
label: v.label,
dates: v.dates.size,
expected: expectedWord[v.period] ?? '—'
})
);
}
private rowsFor(periodEnum: number): SavingsInterestPostingRow[] {
const needle = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (r.posting_period_enum !== periodEnum) {
return false;
}
if (!this.matchesPick(r, periodEnum)) {
return false;
}
if (needle) {
const hay = `${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
});
}
get visibleRows(): SavingsInterestPostingRow[] {
return this.rowsFor(this.activePeriod);
}
countFor(periodEnum: number): number {
return this.rowsFor(periodEnum).length;
}
totalFor(periodEnum: number): number {
return this.rowsFor(periodEnum).reduce((s, r) => s + Number(r.interest_amount ?? 0), 0);
}
get visibleTotal(): number {
return this.totalFor(this.activePeriod);
}
/** Distinct accounts paid on the tab in view — a count of rows would double-count re-postings. */
get visibleAccounts(): number {
return new Set(this.visibleRows.map((r) => r.savings_account_id)).size;
}
get activeMeta(): PostingPeriod {
return this.periods.find((p) => p.enumValue === this.activePeriod) ?? this.periods[1];
}
/**
* The postings on the tab in view, grouped by the day they were paid.
*
* A posting run pays a whole product on one date, so the date is the unit an officer reconciles
* against — "what went out at this month end" — rather than the individual row.
*/
get groupedByDate(): { date: string; rows: SavingsInterestPostingRow[]; total: number; accounts: number }[] {
const byDate = new Map<string, SavingsInterestPostingRow[]>();
for (const row of this.visibleRows) {
const key = row.posting_date ?? '';
const list = byDate.get(key);
if (list) {
list.push(row);
} else {
byDate.set(key, [row]);
}
}
return [...byDate.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(
([
date,
rows
]) => ({
date,
rows,
total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0),
accounts: new Set(rows.map((r) => r.savings_account_id)).size
})
);
}
showPeriod(periodEnum: number): void {
this.activePeriod = periodEnum;
}
resetFilters(): void {
this.fromDate = '';
this.toDate = '';
this.search = '';
this.periodPick = {};
this.load();
}
// ── Presentation ───────────────────────────────────────────────────────────
/** Posting dates arrive as ISO yyyy-MM-dd. */
formatDate(value: string | null | undefined): string {
if (!value) {
return '—';
}
const [
y,
m,
d
] = value.split('-').map(Number);
if (!y || !m || !d) {
return value;
}
return new Date(y, m - 1, d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
}
openAccount(row: SavingsInterestPostingRow): void {
if (row.client_id == null) {
return;
}
this.router.navigate([
'/clients',
row.client_id,
'savings-accounts',
row.savings_account_id,
'transactions'
]);
}
// ── Export ─────────────────────────────────────────────────────────────────
/**
* One sheet per rhythm, so the workbook mirrors the screen.
*
* A rhythm with nothing posted still gets its sheet: an empty Quarterly sheet says the quarter
* has not ended yet, which a missing sheet would leave the reader to guess at.
*/
exportExcel(): void {
const stamp = `${this.fromDate || 'from inception'} to ${this.toDate || 'date'}`;
const sheets: ExportSheetSpec[] = this.periods.map((p) => {
const rows = this.rowsFor(p.enumValue);
return {
sheetName: p.label,
title: `Savings Interest Posted — ${p.label}`,
subtitle: `${p.cadence} · ${stamp} · ${rows.length} posting(s), total ${this.totalFor(p.enumValue).toFixed(2)}`,
headers: [
'Posting Date',
'Account No',
'Customer',
'Product',
'Rate %',
'Balance Before',
'Interest Posted',
'Balance After',
'Branch'
],
widths: [
14,
20,
28,
26,
9,
16,
16,
16,
22
],
rows: rows.map((r) => [
this.formatDate(r.posting_date),
r.account_no,
r.client_name || '—',
r.product_name,
Number(r.interest_rate ?? 0),
Number(r.balance_before ?? 0),
Number(r.interest_amount ?? 0),
Number(r.balance_after ?? 0),
r.office_name || '—'
])
};
});
exportWorkbook(sheets, `savings-interest-posted-${new Date().toISOString().slice(0, 10)}`);
}
}