feat(fixed-deposits): show the posting period and GL-reconciled maturity interest

Each report line now shows the span its interest covers — "01 Oct to
31 Dec" rather than just the end date — and the report header shows
the product's posting cycle (Monthly, Quarterly, Half-yearly, Yearly).

Adds a "Posted outside approval" view: deposit interest posted without
this report's approval, chiefly a deposit that matured before its own
posting cycle completed. Product-wise totals plus a detail list with
when each posting actually reached the account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 00:07:41 +05:30
co-authored by Claude Sonnet 5
parent cec5223378
commit 4acee3e63b
3 changed files with 232 additions and 2 deletions
@@ -25,6 +25,9 @@
<button class="fd-btn fd-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon>Preview interest (today)
</button>
<button class="fd-btn" (click)="openOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="bolt"></fa-icon>Posted outside approval
</button>
</div>
</div>
@@ -227,7 +230,7 @@
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestPostingDate }}</td>
<td class="fd-mono">{{ r.interestPeriodFrom || '—' }} &rarr; {{ r.interestPostingDate }}</td>
<td class="num">&#8377;{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ r.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ r.maturityDate || '—' }}</td>
@@ -250,6 +253,129 @@
</div>
}
@if (showOutsideApproval) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>Fixed deposit interest posted outside this report</h3>
<div class="muted">
A deposit that matured before its own posting cycle completed has its last period settled directly, the
moment it matures &mdash; before any report here could be generated or approved for it. Listed here so
nothing paid to a customer is invisible, approved or not.
</div>
</div>
<div class="fd-total">
<span>Total</span>
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ outsideApprovalRows.length }} posting(s)</span>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<label class="fd-date">
From
<input type="date" [(ngModel)]="outsideApprovalFrom" (change)="loadOutsideApproval()" />
</label>
<label class="fd-date">
To
<input type="date" [(ngModel)]="outsideApprovalTo" (change)="loadOutsideApproval()" />
</label>
<button class="fd-btn fd-btn--slim" (click)="loadOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button class="fd-btn fd-btn--slim" (click)="closeOutsideApproval()">
<fa-icon icon="times"></fa-icon>Close
</button>
</div>
@if (outsideApprovalLoading) {
<div class="fd-empty">Looking for deposit interest posted outside this report&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Product</th>
<th>Scheme</th>
<th class="num">Deposits</th>
<th class="num">Interest</th>
</tr>
</thead>
<tbody>
@for (g of outsideApprovalGroups; track g.productName) {
<tr>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(g.fdType)">{{ fdTypeShort(g.fdType) }}</span>
</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">&#8377;{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="fd-empty">
No fixed deposit interest was posted outside this report in this period.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (outsideApprovalRows.length > 0) {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Customer</th>
<th>Product</th>
<th>Scheme</th>
<th>Interest for</th>
<th class="num">Interest</th>
<th>Posted at</th>
</tr>
</thead>
<tbody>
@for (r of outsideApprovalRows; track r.transactionId) {
<tr>
<td class="fd-mono">{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.productName }}</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestFor }}</td>
<td class="num">&#8377;{{ r.interestAmount | number: '1.2-2' }}</td>
<td class="fd-sub">{{ r.postedAt | date: 'dd MMM yyyy, HH:mm' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
}
</div>
}
<div class="fd-card">
<div class="fd-card-h">
<h2>Reports{{ postingDate ? ' for ' + postingDate : '' }}</h2>
@@ -347,6 +473,7 @@
<h3>
{{ selected.officeName }} &middot; {{ selected.productName }} &mdash; {{ selected.postingDate }}
<span class="fd-chip" [ngClass]="fdTypeClass(selected.fdType)">{{ fdTypeShort(selected.fdType) }}</span>
<span class="fd-badge fd-badge--muted">{{ selected.postingPeriodLabel }}</span>
<span class="fd-badge" [ngClass]="statusClass(selected.status)">{{ selected.status }}</span>
</h3>
<div class="muted">{{ fdTypeLabel(selected.fdType) }}</div>
@@ -384,7 +511,7 @@
<tr>
<td class="fd-mono">{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="fd-mono">{{ l.interestPostingDate }}</td>
<td class="fd-mono">{{ l.interestPeriodFrom || '—' }} &rarr; {{ l.interestPostingDate }}</td>
<td class="num">&#8377;{{ l.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ l.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ l.maturityDate || '—' }}</td>
@@ -18,6 +18,7 @@ import {
FdInterestReport,
FdInterestReportDetail,
FdInterestPostingReportService,
FdOutsideApprovalRow,
FdReportScope,
FdType
} from './fd-interest-posting-report.service';
@@ -478,6 +479,61 @@ export class FdInterestPostingReportComponent implements OnInit {
);
}
// ── Outside approval: deposit interest posted without this report, chiefly at maturity ──
showOutsideApproval = false;
outsideApprovalLoading = false;
outsideApprovalRows: FdOutsideApprovalRow[] = [];
outsideApprovalFrom = '';
outsideApprovalTo = '';
openOutsideApproval(): void {
this.showOutsideApproval = true;
if (!this.outsideApprovalTo) {
this.outsideApprovalTo = this.businessDateIso();
this.outsideApprovalFrom = `${this.outsideApprovalTo.slice(0, 7)}-01`;
}
this.loadOutsideApproval();
}
loadOutsideApproval(): void {
this.outsideApprovalLoading = true;
this.errorMessage = '';
this.reportService
.outsideApproval(this.outsideApprovalFrom || undefined, this.outsideApprovalTo || undefined, undefined, this.tab)
.subscribe({
next: (rows) => {
this.outsideApprovalRows = rows ?? [];
this.outsideApprovalLoading = false;
},
error: (err) => {
this.outsideApprovalLoading = false;
this.outsideApprovalRows = [];
this.errorMessage = this.messageOf(err, 'Could not load deposit interest posted outside this report.');
}
});
}
closeOutsideApproval(): void {
this.showOutsideApproval = false;
}
get outsideApprovalTotal(): number {
return this.outsideApprovalRows.reduce((sum, r) => sum + Number(r.interestAmount || 0), 0);
}
/** Totals by product and scheme, so a branch can see at a glance where its unapproved interest sits. */
get outsideApprovalGroups(): { productName: string; fdType: string; accounts: number; interest: number }[] {
const map = new Map<string, { productName: string; fdType: string; accounts: number; interest: number }>();
this.outsideApprovalRows.forEach((r) => {
const g = map.get(r.productName) ?? { productName: r.productName, fdType: r.fdType, accounts: 0, interest: 0 };
g.accounts += 1;
g.interest += Number(r.interestAmount || 0);
map.set(r.productName, g);
});
return [...map.values()];
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
@@ -37,6 +37,8 @@ export interface FdInterestReport {
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** The product's posting cycle in words — Monthly, Quarterly, Half-yearly, Yearly. */
postingPeriodLabel: string;
/** Whether this user may regenerate it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
blockedReason: string | null;
@@ -49,6 +51,12 @@ export interface FdInterestReportLine {
fdAccountId: number;
accountNo: string;
clientName: string;
/**
* The day after the deposit's last paid period ended, or its activation date for a first-ever
* posting — null for a line posted before this was tracked. Paired with interestPostingDate, it is
* the span the interest was earned over.
*/
interestPeriodFrom: string | null;
/**
* The period this interest is for, as the interest engine dated it — not the day the branch closes.
* A deposit owed two unpaid periods appears on two lines, one per date.
@@ -103,6 +111,28 @@ export interface FdInterestSummaryRow {
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
postingPeriodLabel: string;
}
/**
* One fixed deposit interest transaction that reached the customer's account without this report's
* approval — chiefly a deposit that matured before its own posting cycle completed, whose last period
* is settled directly by the maturity job. View only, for visibility.
*/
export interface FdOutsideApprovalRow {
transactionId: number;
fdAccountId: number;
accountNo: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
interestFor: string;
interestAmount: number;
/** When the posting actually reached the account — not the period it is for. */
postedAt: string;
}
export interface FdInterestPreviewRow {
@@ -114,6 +144,8 @@ export interface FdInterestPreviewRow {
productId: number;
productName: string;
fdType: FdType;
/** The day after the deposit's last paid period ended, or its activation date for the first one. */
interestPeriodFrom: string | null;
/** The period this interest belongs to, as the engine dated it. */
interestPostingDate: string;
depositAmount: number | null;
@@ -230,4 +262,19 @@ export class FdInterestPostingReportService {
if (productId) params = params.set('productId', String(productId));
return this.http.get<FdInterestSummaryRow[]>(`${this.base}/summary`, { params });
}
/** Deposit interest posted without this report's approval — a deposit maturing mid-cycle, chiefly. */
outsideApproval(
fromDate?: string,
toDate?: string,
officeId?: number,
scope?: FdReportScope
): Observable<FdOutsideApprovalRow[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdOutsideApprovalRow[]>(`${this.base}/outside-approval`, { params });
}
}