Compare commits

..
Author SHA1 Message Date
Sonali Likhitkar f499a6b57e product creation changes and preview changes 2026-09-01 10:46:36 +05:30
Sonali LikhitkarandClaude Fable 5 f0b4e98e71 feat(loan-products): extend loan product stepper with legacy account-type fields
Add UI-only fields across the details, settings, charges, terms, and
accounting steps to mirror the legacy Account Type Creation screen
(classification, cheque book, interest, and additional settings).
These are stripped from the create/edit payload via
CUSTOM_UI_ONLY_FIELDS until a backend mechanism (e.g. a linked data
table) exists to persist them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:27:36 +05:30
133 changed files with 3621 additions and 10880 deletions
+7
View File
@@ -69,3 +69,10 @@ src/environments/.env.ts
/playwright-report/
/blob-report/
/playwright/.cache/
# JVM crash / replay logs
hs_err_pid*.log
replay_pid*.log
# Microsoft Office temporary lock files
~$*.xlsx
+1 -2
View File
@@ -36,8 +36,7 @@
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug",
"headers:check": "node scripts/check-file-headers.js",
"headers:add": "node scripts/add-file-headers.js",
"mock:dip": "node \"../cbsnexora-backend/scripts/mock-dip-lookup-service.js\""
"headers:add": "node scripts/add-file-headers.js"
},
"dependencies": {
"@angular/animations": "20.3.16",
-20
View File
@@ -44,26 +44,6 @@ export class AccountingService {
return this.http.get('/accounting/profitandloss', { params: httpParams });
}
/**
* How much profit a share dividend may actually be declared out of — net profit reduced by the
* statutory appropriations that legally precede a dividend (Statutory Reserve Fund, education
* fund) and by dividends already declared for other share products over an overlapping period.
* @param {string} fromDate Period start, yyyy-MM-dd.
* @param {string} toDate Period end, yyyy-MM-dd.
* @param {string} excludeProductId Leave this product's own declarations out of `alreadyDeclared`
* (its overlapping declaration is separately blocked outright, so counting it would double up).
* @returns {Observable<any>} `{ netProfit, statutoryReserveAmount, educationFundAmount,
* distributableProfit, alreadyDeclared, availableForDeclaration, maximumRatePercentage,
* existingDeclarations[] }`.
*/
getDistributableProfit(fromDate: string, toDate: string, excludeProductId?: string): Observable<any> {
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
if (excludeProductId) {
httpParams = httpParams.set('excludeProductId', excludeProductId);
}
return this.http.get('/accounting/distributable-profit', { params: httpParams });
}
/**
* Total share dividend actually paid to members in a date range — derived from the Dividend
* Payable GL account's own ledger (no separate "Dividend Paid" account needed).
@@ -1,169 +0,0 @@
<!--
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="pg ar-print">
<header class="pg-head">
<div>
<div class="pg-eyebrow">REPORTS · ACCOUNT REGISTER</div>
<h1 class="pg-title">Account Register</h1>
<p class="pg-sub">
Every account as it stood on the chosen date — which ones opened and closed that day, the balance each held
then, and what it holds now.
</p>
</div>
<div class="pg-toolbar ar-noprint">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>As on date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Shown only on paper, where the toolbar is hidden and the date has to be stated. -->
<p class="ar-printhead">Account Register as on {{ date }} · {{ section }}</p>
<div class="db-filters ar-noprint">
<label class="pg-field">
<span>Section</span>
<select [(ngModel)]="section" (change)="load()">
@for (s of sections; track s.value) {
<option [value]="s.value">{{ s.label }}</option>
}
</select>
</label>
<label class="pg-field ar-check">
<span>Movement</span>
<span class="ar-check__box">
<input type="checkbox" [(ngModel)]="movementOnly" />
Only accounts opened / closed on this date
</span>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Account no, client, product, status…" />
</label>
<button type="button" class="pg-btn" (click)="printRegister()" [disabled]="filteredRows.length === 0">
<mat-icon>print</mat-icon> Print
</button>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded) {
<!-- What actually happened that day, read before the list itself. -->
<div class="ar-summary">
<div class="ar-summary__item ar-summary__item--in">
<span class="ar-summary__label">Accounts opened on {{ date }}</span>
<span class="ar-summary__value">{{ openedCount }}</span>
</div>
<div class="ar-summary__item ar-summary__item--out">
<span class="ar-summary__label">Accounts closed on {{ date }}</span>
<span class="ar-summary__value">{{ closedCount }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Balance on {{ date }}</span>
<span class="ar-summary__value">₹{{ shownBalanceOnDate | number: '1.2-2' }}</span>
</div>
<div class="ar-summary__item">
<span class="ar-summary__label">Current balance</span>
<span class="ar-summary__value">₹{{ shownCurrentBalance | number: '1.2-2' }}</span>
</div>
</div>
}
@if (!loading && loaded && filteredRows.length === 0) {
<div class="pg-empty">
<mat-icon>account_balance_wallet</mat-icon>
<p>
@if (movementOnly) {
No account was opened or closed on {{ date }}.
} @else {
No accounts existed on {{ date }}.
}
</p>
</div>
}
@if (filteredRows.length > 0) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Section</th>
<th>Client</th>
<th>Product</th>
<th>Status</th>
<th>Opened</th>
<th>Closed</th>
<th class="num">Balance on {{ date }}</th>
<th class="num">Current Balance</th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.section + r.accountId) {
<tr [class.ar-row--opened]="r.openedOnDate" [class.ar-row--closed]="r.closedOnDate">
<td>
<span class="pg-chip">{{ r.accountNo }}</span>
@if (r.openedOnDate) {
<span class="ar-tag ar-tag--in">OPENED</span>
}
@if (r.closedOnDate) {
<span class="ar-tag ar-tag--out">CLOSED</span>
}
</td>
<td>{{ r.section }}</td>
<td>{{ r.clientName ?? '—' }}</td>
<td>{{ r.productName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.openedOn ?? '—' }}</td>
<td>{{ r.closedOn ?? '—' }}</td>
<td class="num">₹{{ r.balanceOnDate | number: '1.2-2' }}</td>
<td class="num">
₹{{ r.currentBalance | number: '1.2-2' }}
@if (r.section === 'SHARE' && r.sharesHeld) {
<span class="db-muted">· {{ r.sharesHeld }} sh</span>
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="7">
{{ filteredRows.length }} account(s) · {{ openedCount }} opened · {{ closedCount }} closed
</td>
<td class="num">₹{{ shownBalanceOnDate | number: '1.2-2' }}</td>
<td class="num">₹{{ shownCurrentBalance | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -1,144 +0,0 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss; badges, filters and muted text are shared with the
day book. Only what belongs to the account register lives here. */
.ar-summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
&__item {
flex: 1 1 180px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
&--in {
border-left: 4px solid #16a34a;
}
&--out {
border-left: 4px solid #dc2626;
}
}
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 4px;
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
}
/* Checkbox sits inside a pg-field, which is laid out for inputs — this keeps the label and box on
one line without fighting that. */
.ar-check {
&__box {
display: flex;
align-items: center;
gap: 6px;
height: 38px;
font-size: 12px;
color: #475569;
white-space: nowrap;
}
}
/* Marks the accounts that actually moved on the chosen date — the reason someone opens a register
for a past day at all. */
.ar-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
&--in {
background: #dcfce7;
color: #15803d;
}
&--out {
background: #fee2e2;
color: #b91c1c;
}
}
.ar-row--opened td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.ar-row--closed td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
/* Only shown on paper: the screen already states the date in the toolbar. */
.ar-printhead {
display: none;
}
/* A register is meant to be printed and filed, so paper gets its own treatment: the toolbar and
filters go, the table gains borders, and rows are not split across pages. */
@media print {
.ar-noprint {
display: none !important;
}
.ar-printhead {
display: block;
margin: 0 0 10px;
font-size: 13px;
font-weight: 800;
color: #000;
}
.pg {
padding: 0;
}
.ar-summary__item {
border: 1px solid #999;
}
.pg-table {
font-size: 10px;
th,
td {
border: 1px solid #999;
padding: 4px 6px;
color: #000;
}
}
tr {
page-break-inside: avoid;
}
/* Table headers repeat on every page, so a multi-page register stays readable. */
thead {
display: table-header-group;
}
}
@@ -1,221 +0,0 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface RegisterRow {
accountId: number;
accountNo: string;
section: 'SAVINGS' | 'FD' | 'RD' | 'SHARE';
clientName: string | null;
productName: string | null;
status: string;
openedOn: string | null;
closedOn: string | null;
/** True when this account was opened (or closed) on the very date being viewed. */
openedOnDate: boolean;
closedOnDate: boolean;
/** The account's balance as it stood on the register's date, for every section. */
balanceOnDate: number | null;
currentBalance: number | null;
sharesHeld?: number;
}
/**
* Account register for one date: which accounts opened and closed that day, each account's balance
* as it stood then, and what it holds now.
*/
@Component({
selector: 'mifosx-account-register',
templateUrl: './account-register.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./account-register.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountRegisterComponent implements OnInit {
private service = inject(CashierService);
date = '';
section: 'ALL' | 'SAVINGS' | 'FD' | 'RD' | 'SHARE' = 'ALL';
search = '';
/** Narrows to just the accounts that opened or closed on the chosen date. */
movementOnly = false;
rows: RegisterRow[] = [];
openedCount = 0;
closedCount = 0;
totalBalanceOnDate = 0;
totalCurrentBalance = 0;
loading = false;
loaded = false;
errorMsg = '';
readonly sections = [
{ value: 'ALL', label: 'All accounts' },
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'FD', label: 'Fixed Deposit' },
{ value: 'RD', label: 'Recurring Deposit' },
{ value: 'SHARE', label: 'Share' }
];
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.accountRegister(this.date, this.section)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not load the account register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.accounts ?? [];
this.openedCount = Number(res?.openedCount ?? 0);
this.closedCount = Number(res?.closedCount ?? 0);
this.totalBalanceOnDate = Number(res?.totalBalanceOnDate ?? 0);
this.totalCurrentBalance = Number(res?.totalCurrentBalance ?? 0);
});
}
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Free-text narrowing over the rows already fetched — no round trip per keystroke. */
get filteredRows(): RegisterRow[] {
const base = this.movementOnly ? this.rows.filter((r) => r.openedOnDate || r.closedOnDate) : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.accountNo,
r.clientName,
r.productName,
r.status,
r.section
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a filter or search still reconciles. */
get shownBalanceOnDate(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.balanceOnDate ?? 0), 0);
}
get shownCurrentBalance(): number {
return this.filteredRows.reduce((s, r) => s + Number(r.currentBalance ?? 0), 0);
}
statusClass(status: string): string {
switch (status) {
case 'ACTIVE':
return 'db-badge db-badge--ok';
case 'CLOSED':
case 'PREMATURELY CLOSED':
case 'REJECTED':
case 'WITHDRAWN':
return 'db-badge db-badge--rejected';
case 'SUBMITTED':
case 'APPROVED':
return 'db-badge db-badge--pending';
default:
return 'db-badge';
}
}
exportCsv(): void {
const header = [
'Account No',
'Section',
'Client',
'Product',
'Status',
'Opened On',
'Closed On',
`Balance on ${this.date}`,
'Current Balance'
];
const lines = this.filteredRows.map((r) =>
[
r.accountNo,
r.section,
r.clientName ?? '',
r.productName ?? '',
r.status,
r.openedOn ?? '',
r.closedOn ?? '',
r.balanceOnDate == null ? '' : String(r.balanceOnDate),
String(r.currentBalance ?? 0)
]
// Quote every field and double embedded quotes: client names routinely contain commas.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `account-register-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** Hands the page to the browser's print dialog; print styles drop the toolbar and filters. */
printRegister(): void {
window.print();
}
}
@@ -1,337 +0,0 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · {{ missedOnly ? 'MISSED CLOSE' : 'DAY REGISTER' }}</div>
<h1 class="pg-title">{{ missedOnly ? 'Missed Close Register' : 'Day Open / Day Close' }}</h1>
<p class="pg-sub">
@if (missedOnly) {
Business days that were opened and never closed. While a day stays open its closing balance never carries
forward, a back-dated entry can still be posted into it, and any cash difference on that date goes unnoticed.
} @else {
The branch's business day, opened each morning and closed each evening. A day left open never carries its
closing balance forward, and any cash difference on that date goes unnoticed — so the ones still hanging are
listed first.
}
</p>
</div>
<div class="pg-toolbar">
@if (canOpenToday && !missedOnly) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startOpen()">
<mat-icon>lock_open</mat-icon> Open Day
</button>
}
<button type="button" class="pg-btn" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="bd-success">{{ successMsg }}</div>
}
@if (!canOperate && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see the register but not open or close a day — a Clerk, Head Cashier or Bank Manager does that, since a
cashier works inside the day and cannot declare it closed.
</div>
}
<!-- Missed closes first: these are the ones that need action. -->
@if (missed.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>{{ missed.length }} business day{{ missed.length === 1 ? '' : 's' }} never closed</strong>
</div>
@for (m of missed; track m.id) {
<div class="bd-missed">
<span class="pg-chip">{{ m.businessDate }}</span>
<strong class="bd-missed__age"> open {{ m.daysOpen }} day{{ m.daysOpen === 1 ? '' : 's' }} </strong>
<span class="db-muted">
opened {{ timeOnly(m.openedAt) }} by {{ m.openedByName }} · opening ₹{{ m.openingCash | number: '1.2-2' }}
</span>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--warn bd-missed__act" (click)="startClose(m)">
Close this day
</button>
}
</div>
}
</div>
}
<!-- On the missed-close screen, nothing to report is itself the answer — and has to be stated,
or the page looks like it failed to load. -->
@if (missedOnly && loaded && missed.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>No missed closes. Every business day opened for this branch has been closed.</p>
</div>
}
<!-- Current day -->
@if (!missedOnly && openDay) {
<div class="bd-current">
<div class="bd-current__main">
<span class="db-badge db-badge--pending">DAY OPEN</span>
<strong class="bd-current__date">{{ openDay.businessDate }}</strong>
<span class="db-muted">
opened {{ timeOnly(openDay.openedAt) }} by {{ openDay.openedByName }} · opening cash ₹{{
openDay.openingCash | number: '1.2-2'
}}
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
<span class="db-badge">NO DAY OPEN</span>
<span class="db-muted">Open the business day before any cash transaction is accepted.</span>
</div>
</div>
}
<!-- Open-day form -->
@if (showOpenForm) {
<div class="db-panel bd-panel">
<div class="db-panel__head">
<strong>Open business day</strong>
<button type="button" class="db-icon-btn" (click)="cancelOpen()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<label class="pg-field">
<span>Business date</span>
<input type="date" [(ngModel)]="openDate" />
</label>
<label class="pg-field">
<span>Opening cash (leave blank to carry forward)</span>
<input type="number" step="0.01" [(ngModel)]="openingCash" placeholder="Carried from last close" />
</label>
</div>
<p class="bd-hint">
Left blank, yesterday's closing figure carries forward automatically — which is what keeps the opening balance
from drifting.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitOpen()" [disabled]="opening || !openDate">
Open Day
</button>
<button type="button" class="pg-btn" (click)="cancelOpen()">Cancel</button>
</div>
</div>
}
<!-- Close-day form -->
@if (closeTarget) {
<div class="db-panel bd-panel" [class.bd-panel--late]="isLateClose">
<div class="db-panel__head">
<strong>Close business day — {{ closeTarget.businessDate }}</strong>
<button type="button" class="db-icon-btn" (click)="cancelClose()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
@if (isLateClose) {
<p class="db-panel__warn">
This day is being closed after its own date. It will be recorded as a
<strong>force close</strong>, with the reason below kept permanently against it.
</p>
}
@if (previewLoading) {
<p class="db-muted">Working out the day's figure…</p>
}
<!-- The arithmetic, spelled out. Whoever closes the day can see where the expected figure
comes from instead of being asked to agree with a number they cannot check. -->
@if (preview) {
<div class="bd-calc">
<div class="bd-calc__row">
<span>Opening cash (carried forward)</span>
<span class="bd-calc__amt">₹{{ preview.openingCash | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash deposits taken (approved)</span>
<span class="bd-calc__amt">₹{{ preview.deposits | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--in">
<span>+ Cash drawn from vault</span>
<span class="bd-calc__amt">₹{{ preview.vaultIn | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash paid out (approved withdrawals)</span>
<span class="bd-calc__amt">₹{{ preview.withdrawals | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--out">
<span>− Cash settled back to vault</span>
<span class="bd-calc__amt">₹{{ preview.settledOut | number: '1.2-2' }}</span>
</div>
<div class="bd-calc__row bd-calc__row--total">
<span>Cash the branch should be holding</span>
<span class="bd-calc__amt">₹{{ preview.expectedCash | number: '1.2-2' }}</span>
</div>
</div>
}
<div class="db-panel__body">
<label class="pg-field">
<span>Physical cash counted</span>
<input type="number" step="0.01" [(ngModel)]="closingCash" placeholder="0.00" />
</label>
@if (preview && liveDifference !== null) {
@if (liveDifference === 0) {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">Tallied</span>
</div>
} @else {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">
{{ liveDifference > 0 ? 'Excess' : 'Short' }} ₹{{
liveDifference < 0 ? -liveDifference : (liveDifference | number: '1.2-2')
}}
</span>
</div>
}
}
@if (preview && liveDifference !== 0) {
<button type="button" class="pg-btn" (click)="useExpected()">Use expected figure</button>
}
</div>
<p class="bd-hint">
Pre-filled with what the books expect — change it only if the drawer counted something different, and that
difference is then recorded against the day.
</p>
@if (isLateClose) {
<label class="pg-field bd-panel__wide">
<span>Why was this day left open? (required)</span>
<input
type="text"
[(ngModel)]="forceReason"
placeholder="e.g. cashier on leave, cash not settled that evening"
/>
</label>
}
<label class="pg-field bd-panel__wide">
<span>Remarks (optional)</span>
<input type="text" [(ngModel)]="closeRemarks" placeholder="Anything worth recording about this day" />
</label>
<p class="bd-hint">
The figure above is stored against the record, so a difference stays readable later even if a transaction is
reversed afterwards.
</p>
<div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
{{ isLateClose ? 'Force Close Day' : 'Close Day' }}
</button>
<button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
</div>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && days.length === 0 && !missedOnly) {
<div class="pg-empty">
<mat-icon>event_note</mat-icon>
<p>No business day has been opened for this branch yet.</p>
</div>
}
<!-- The register -->
@if (days.length > 0 && !missedOnly) {
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Business Date</th>
<th>Status</th>
<th>Opened By</th>
<th class="num">Opening ₹</th>
<th>Closed By</th>
<th class="num">Closing ₹</th>
<th class="num">Book ₹</th>
<th>Difference</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (d of days; track d.id) {
<tr>
<td>
<span class="pg-chip">{{ d.businessDate }}</span>
</td>
<td>
@if (d.status === 'OPEN') {
<span class="db-badge db-badge--pending">OPEN</span>
} @else if (d.forceClosed) {
<span class="db-badge db-badge--rejected" [title]="d.forceCloseReason ?? ''">FORCE CLOSED</span>
} @else {
<span class="db-badge db-badge--ok">CLOSED</span>
}
</td>
<td>
{{ d.openedByName ?? '—' }}
<span class="db-muted">· {{ timeOnly(d.openedAt) }}</span>
</td>
<td class="num">₹{{ d.openingCash | number: '1.2-2' }}</td>
<td>
{{ d.closedByName ?? '—' }}
@if (d.closedAt) {
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
}
</td>
<td class="num">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td>
@if (d.cashDifference == null) {
—
} @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span>
} @else {
<span class="db-badge db-badge--rejected">{{ differenceLabel(d.cashDifference) }}</span>
}
</td>
<td class="bd-note">
@if (d.forceCloseReason) {
<strong>Left open:</strong> {{ d.forceCloseReason }}
}
@if (d.remarks) {
<span class="db-muted">{{ d.remarks }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -1,172 +0,0 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss; the badges, panel and comparison tiles are shared
with the day book. Only what is specific to the day register lives here. */
/* Read-only notice for a cashier. Defined here rather than borrowed from the exceptions screen so
this component does not depend on another component's stylesheet. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #14532d;
font-size: 13px;
}
/* One row per never-closed day, inside the shared red exception panel. */
.bd-missed {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 7px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
&__age {
color: #b91c1c;
}
/* Pushed right so the date and age read before the action. */
&__act {
margin-left: auto;
height: 28px;
font-size: 11px;
}
}
/* The branch's current day — the one thing someone opening this screen is looking for. */
.bd-current {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 14px 16px;
margin-bottom: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #f59e0b;
border-radius: 8px;
/* No day open at all: nothing is running, so the amber "in progress" edge would mislead. */
&--shut {
border-left-color: #94a3b8;
background: #f8fafc;
}
&__main {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__date {
font-size: 15px;
color: #0d1b2a;
}
.pg-btn {
margin-left: auto;
}
}
.bd-panel {
margin-bottom: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* A late close is a different act from an ordinary one, and looks like it. */
&--late {
border-left: 4px solid #dc2626;
}
&__wide {
display: block;
margin-top: 12px;
max-width: 640px;
}
}
/* The closing figure's arithmetic, laid out as a running sum so each line can be checked against
the screen it came from. */
.bd-calc {
max-width: 520px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 5px 0;
color: #475569;
&--in .bd-calc__amt {
color: #15803d;
}
&--out .bd-calc__amt {
color: #b91c1c;
}
&--total {
margin-top: 4px;
padding-top: 8px;
border-top: 2px solid #e2e8f0;
font-weight: 800;
font-size: 14px;
color: #0d1b2a;
}
}
&__amt {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}
}
.bd-hint {
margin: 10px 0 0;
font-size: 11px;
color: #94a3b8;
}
.bd-note {
max-width: 280px;
font-size: 11px;
line-height: 1.4;
}
@@ -1,284 +0,0 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface BranchDay {
id: number;
officeId: number;
officeName: string | null;
businessDate: string;
openedByName: string | null;
openedAt: string | null;
openingCash: number | null;
closedByName: string | null;
closedAt: string | null;
closingCash: number | null;
bookCash: number | null;
cashDifference: number | null;
forceClosed: boolean;
forceCloseReason: string | null;
remarks: string | null;
status: 'OPEN' | 'CLOSED';
/** Only present on the missed-close list. */
daysOpen?: number;
}
/**
* Branch day register — opening and closing the branch's business day, and the list of days that
* were opened and never closed.
*
* A day left open is not a tidiness problem: its closing balance never carries forward, a
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
*/
@Component({
selector: 'mifosx-branch-day',
templateUrl: './branch-day.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./branch-day.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class BranchDayComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/**
* Set from the route: the missed-close register is the same data as the day register, shown with
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
* Close Register" looked like nothing had happened whenever the branch was up to date.
*/
missedOnly = false;
days: BranchDay[] = [];
missed: BranchDay[] = [];
openDay: any = null;
today = '';
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
canOperate = false;
loading = false;
loaded = false;
errorMsg = '';
successMsg = '';
// Open-day form
showOpenForm = false;
openDate = '';
openingCash: number | null = null;
opening = false;
// Close-day form. The date is fixed by which day is being closed, never typed.
closeTarget: BranchDay | null = null;
closingCash: number | null = null;
closeRemarks = '';
forceReason = '';
closing = false;
ngOnInit(): void {
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.branchDayRegister()
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.days = res?.days ?? [];
this.openDay = res?.openDay ?? null;
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
});
this.service
.branchDayMissed()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.missed = res?.missed ?? [];
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay;
}
startOpen(): void {
this.showOpenForm = true;
this.openDate = this.today;
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
// over it is how a carried balance drifts.
this.openingCash = null;
this.successMsg = '';
this.errorMsg = '';
}
cancelOpen(): void {
this.showOpenForm = false;
}
submitOpen(): void {
if (!this.openDate) return;
this.opening = true;
this.errorMsg = '';
this.service
.openBranchDay(this.openDate, this.openingCash ?? undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.opening = false;
if (res) {
this.showOpenForm = false;
this.successMsg = `Business day ${res.businessDate} opened.`;
this.load();
}
});
}
/** The day's expected figure and the working behind it, fetched when the close form opens. */
preview: any = null;
previewLoading = false;
startClose(day: BranchDay): void {
this.closeTarget = day;
this.closingCash = null;
this.closeRemarks = '';
this.forceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.preview = null;
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
// closed on what was physically counted — but in the ordinary case where the drawer tallies
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
this.previewLoading = true;
this.service
.branchDayClosePreview(day.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.previewLoading = false;
this.preview = res;
if (res?.expectedCash != null && this.closingCash == null) {
this.closingCash = Number(res.expectedCash);
}
});
}
/** Difference the form would record right now, so it is visible before saving. */
get liveDifference(): number | null {
if (!this.preview || this.closingCash == null) return null;
return Number(this.closingCash) - Number(this.preview.expectedCash ?? 0);
}
/** Puts the expected figure back after an edit. */
useExpected(): void {
if (this.preview?.expectedCash != null) {
this.closingCash = Number(this.preview.expectedCash);
}
}
cancelClose(): void {
this.closeTarget = null;
}
/** A date earlier than today is a late close, and has to say why it was left open. */
get isLateClose(): boolean {
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
}
get canSubmitClose(): boolean {
if (!this.closeTarget || this.closingCash == null || this.closing) return false;
return !this.isLateClose || !!this.forceReason.trim();
}
submitClose(): void {
const day = this.closeTarget;
if (!day || this.closingCash == null) return;
this.closing = true;
this.errorMsg = '';
this.service
.closeBranchDay(
day.businessDate,
Number(this.closingCash),
this.closeRemarks.trim() || undefined,
this.isLateClose ? this.forceReason.trim() : undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.closing = false;
if (res) {
this.closeTarget = null;
const diff = Number(res.cashDifference ?? 0);
this.successMsg =
diff === 0
? `Business day ${res.businessDate} closed — cash tallied.`
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
this.load();
}
});
}
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
differenceLabel(d: number | null): string {
if (d == null) return '—';
const n = Number(d);
if (n === 0) return 'Tallied';
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
}
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
timeOnly(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
}
-41
View File
@@ -19,10 +19,6 @@ import { InterCashierTransferComponent } from './inter-cashier-transfer/inter-ca
import { CashierPositionComponent } from './cashier-position/cashier-position.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
import { ShareCollectionComponent } from './share-collection/share-collection.component';
@@ -132,33 +128,6 @@ const routes: Routes = [
}
]
},
{
// Branch-level operations, kept out of the cashier tree on purpose: opening and closing the
// day governs the whole branch, and is not something a cashier does.
path: 'branch-ops',
data: { title: 'Branch Operations', breadcrumb: 'Branch Operations' },
children: [
{
path: 'day-register',
component: BranchDayComponent,
data: { title: 'Day Open / Day Close', breadcrumb: 'Day Register' }
},
{
// Same screen, narrowed to the days that were never closed. A separate route rather than
// a second link to the register, so choosing it visibly does something.
path: 'missed-close',
component: BranchDayComponent,
data: { title: 'Missed Close Register', breadcrumb: 'Missed Close', missedOnly: true }
},
{
// Lives here alongside the day register because both answer "what did this branch look
// like on that date"; it is linked from the Reports menu, where people go to find it.
path: 'account-register',
component: AccountRegisterComponent,
data: { title: 'Account Register', breadcrumb: 'Account Register' }
}
]
},
{
path: 'cashier',
data: { title: 'Cashier', breadcrumb: 'Cashier' },
@@ -203,16 +172,6 @@ const routes: Routes = [
component: CashHistoryComponent,
data: { title: 'Cash History', breadcrumb: 'Cash History' }
},
{
path: 'day-book',
component: DayBookComponent,
data: { title: 'Day Book', breadcrumb: 'Day Book' }
},
{
path: 'voucher-exceptions',
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{
path: 'closing-report',
component: ClosingCashReportComponent,
-207
View File
@@ -113,146 +113,6 @@ export class CashierService {
const params = new HttpParams().set('date', date);
return this.http.get('/cashreports/closing', { params });
}
/**
* Every voucher raised on one date — deposits, withdrawals, disbursements, collections and
* bank transfers in a single list, with the day's credit and debit totals.
*/
dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (side) params = params.set('side', side);
if (status) params = params.set('status', status);
return this.http.get('/cashreports/day-book', { params });
}
// ── Voucher physical verification & attachments ──
// The voucher number always travels in the body or a query parameter, never as a path segment:
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
// encoded slashes with a 400 before the request reaches the resource.
/** Records the outcome of checking a voucher against the paper slip. */
verifyVoucher(
voucherNo: string,
status: 'VERIFIED' | 'EXCEPTION',
slipAmount?: number,
remarks?: string
): Observable<any> {
return this.http.post('/vouchers/verify', {
voucherNo,
status,
slipAmount: slipAmount != null ? String(slipAmount) : null,
remarks: remarks ?? null,
locale: 'en'
});
}
uploadVoucherAttachment(
voucherNo: string,
fileName: string,
contentType: string,
fileData: string,
description?: string
): Observable<any> {
return this.http.post('/vouchers/attachments', {
voucherNo,
fileName,
contentType,
fileData,
description: description ?? null
});
}
listVoucherAttachments(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/attachments', { params });
}
deleteVoucherAttachment(id: number): Observable<any> {
return this.http.delete(`/vouchers/attachments/${id}`);
}
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
downloadVoucherAttachment(id: number): Observable<Blob> {
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
}
/**
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
* raised on Friday still needs answering on Monday.
*/
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
const params = new HttpParams().set('status', status);
return this.http.get('/vouchers/exceptions', { params });
}
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
}
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
*/
accountRegister(date: string, section?: string): Observable<any> {
let params = new HttpParams().set('date', date);
if (section && section !== 'ALL') params = params.set('section', section);
return this.http.get('/accountregister', { params });
}
// ── Branch day register ──
/** The branch's day register, plus which day is currently open. Defaults to the caller's office. */
branchDayRegister(officeId?: number, limit?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
if (limit) params = params.set('limit', String(limit));
return this.http.get('/branchday', { params });
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/missed', { params });
}
/**
* What the day should close at, and the arithmetic behind it, so the closing figure does not
* have to be worked out by hand.
*/
branchDayClosePreview(businessDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (businessDate) params = params.set('businessDate', businessDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/branchday/close-preview', { params });
}
openBranchDay(businessDate: string, openingCash?: number, officeId?: number): Observable<any> {
return this.http.post('/branchday/open', {
businessDate,
openingCash: openingCash != null ? String(openingCash) : null,
officeId: officeId ?? null,
locale: 'en'
});
}
/** forceCloseReason is required when closing a date earlier than today. */
closeBranchDay(
businessDate: string,
closingCash: number,
remarks?: string,
forceCloseReason?: string,
officeId?: number
): Observable<any> {
return this.http.post('/branchday/close', {
businessDate,
closingCash: String(closingCash),
remarks: remarks ?? null,
forceCloseReason: forceCloseReason ?? null,
officeId: officeId ?? null,
locale: 'en'
});
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -328,71 +188,4 @@ export class CashierService {
getShareCollectionStatus(shareAccountId: number): Observable<any> {
return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
}
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
// (%2F) outright with a 400 before the request ever reaches the resource.
// clientId lets the backend adopt a cashier deposit made straight to the Share Sundry GL,
// which carries no member of its own.
let params = new HttpParams().set('voucherNo', voucherNo);
if (clientId) {
params = params.set('clientId', String(clientId));
}
return this.http.get('/shareadvancecollection/by-voucher', { params });
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
applyShareAdvance(collectionId: number, data: any): Observable<any> {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
/**
* Whether this share account can be settled from the member's linked savings, and for how much.
*
* Reports a shortfall before the clerk raises anything, so the member can be told at the counter
* rather than after a request has been submitted and has to be rejected.
* @returns {Observable<any>} `{ eligible, reason, shareAmount, chargeAmount, totalRequired,
* savingsAccountNo, savingsBalance }`.
*/
getShareSavingsDebitEligibility(shareAccountId: number): Observable<any> {
return this.http.get(`/sharesavingsdebit/eligibility/${shareAccountId}`);
}
/**
* Records a request to settle a share application by debiting the member's savings. Nothing moves
* until a manager approves it — unlike the cash route, where the cashier's receipt is already the
* second pair of eyes.
*/
submitShareSavingsDebit(data: any): Observable<any> {
return this.http.post('/sharesavingsdebit', data);
}
/** Savings-debit requests, PENDING by default. */
getShareSavingsDebitRequests(status?: string, shareAccountId?: number): Observable<any> {
const params: string[] = [];
if (status) {
params.push(`status=${status}`);
}
if (shareAccountId) {
params.push(`shareAccountId=${shareAccountId}`);
}
return this.http.get(`/sharesavingsdebit${params.length ? '?' + params.join('&') : ''}`);
}
/** Debits the member's savings and issues the shares. Manager only, and not the submitting clerk. */
approveShareSavingsDebit(id: number): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/approve`, {});
}
rejectShareSavingsDebit(id: number, rejectionReason?: string): Observable<any> {
return this.http.post(`/sharesavingsdebit/${id}/reject`, { rejectionReason });
}
}
@@ -1,393 +0,0 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · DAY BOOK</div>
<h1 class="pg-title">Day Book</h1>
<p class="pg-sub">
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
transfers, with credit and debit totals.
</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
<mat-icon>chevron_left</mat-icon>
</button>
<label class="pg-field">
<span>Date</span>
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
</label>
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
<mat-icon>chevron_right</mat-icon>
</button>
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
<!-- Filters -->
<div class="db-filters">
<label class="pg-field">
<span>Side</span>
<select [(ngModel)]="side" (change)="load()">
<option value="">All</option>
<option value="CR">CR · Credit (money in)</option>
<option value="DR">DR · Debit (money out)</option>
</select>
</label>
<label class="pg-field">
<span>Status</span>
<select [(ngModel)]="status" (change)="load()">
<option value="">All</option>
<option value="APPROVED">Approved</option>
<option value="PENDING">Pending</option>
<option value="REJECTED">Rejected</option>
</select>
</label>
<label class="pg-field db-filters__search">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
</label>
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
<mat-icon>download</mat-icon> CSV
</button>
</div>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
flag raised for investigation is useless if someone has to scroll to find it. -->
@if (!loading && exceptionRows.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
</strong>
@if (resolvedExceptionCount > 0) {
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
}
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
</button>
</div>
@for (e of exceptionRows; track e.voucherNo) {
<div class="db-exceptions__item">
<span class="pg-chip">{{ e.voucherNo }}</span>
<span class="db-exceptions__amt">
System ₹{{ e.amount | number: '1.2-2' }}
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
<strong class="db-exceptions__diff"
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
>
}
</span>
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
</div>
}
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && rows.length === 0) {
<div class="pg-empty">
<mat-icon>receipt_long</mat-icon>
<p>No vouchers were raised on {{ date }}.</p>
</div>
}
@if (!loading && filteredRows.length > 0) {
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Credit · CR (money in)</span>
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
<span class="closing-summary__label">Debit · DR (money out)</span>
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
>
<span class="closing-summary__label">Net</span>
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
</div>
</div>
<p class="db-note">
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
</p>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Dr/Cr</th>
<th>Type</th>
<th>Account</th>
<th>Party</th>
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>By</th>
<th>Approved By</th>
<th>Slip Check</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
<td>
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
</td>
<td>
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
{{ r.side }}
</span>
</td>
<td>{{ r.kind }}</td>
<td>
{{ r.accountNo ?? '—' }}
@if (r.accountType) {
<span class="db-muted">· {{ r.accountType }}</span>
}
</td>
<td>{{ r.party ?? '—' }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.paymentTypeName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@if (r.approvedBy && r.actionedAt) {
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
}
</td>
<td>
@if (r.verifyStatus === 'VERIFIED') {
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
>✓ MATCHED</span
>
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
<span
class="db-badge db-badge--ok"
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
>
✓ RESOLVED
</span>
} @else if (r.verifyStatus === 'EXCEPTION') {
<span
class="db-badge db-badge--rejected"
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
>
⚠ EXCEPTION
</span>
} @else {
<span class="db-muted">Not checked</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
<td class="db-actions">
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
<mat-icon>fact_check</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
<mat-icon>attach_file</mat-icon>
@if (r.attachmentCount > 0) {
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
}
</button>
</td>
</tr>
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
enough: it cannot be printed, exported, or read without a mouse. -->
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
<td colspan="13">
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
<strong>Exception</strong>
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
}
— {{ r.verifyRemarks }}
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
@if (r.resolutionStatus) {
<div class="db-exc-row__resolution">
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
{{ r.resolutionNotes }}
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
</div>
}
</td>
</tr>
}
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
next to the row they describe. -->
@if (verifyRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Slip check — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
</div>
<label class="pg-field">
<span>Amount written on slip</span>
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
</label>
@if (verifySlipBlank) {
<div class="db-cmp">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">—</span>
</div>
} @else if (verifyMismatch) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Match</span>
<span class="db-cmp__value">Amounts agree</span>
</div>
}
</div>
<label class="pg-field db-panel__remarks">
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
<input
type="text"
[(ngModel)]="verifyRemarks"
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
/>
</label>
@if (verifyMismatch) {
<p class="db-panel__warn">
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
change the posted amount.
</p>
}
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveVerify()"
[disabled]="verifySaving || verifySlipBlank"
>
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
</button>
<!-- Raising an exception on an already-matched voucher stays available: a
slip cleared in haste, or a difference noticed later, has to be
recordable without erasing the earlier verdict. -->
@if (!verifyMismatch) {
<button
type="button"
class="pg-btn pg-btn--warn"
(click)="raiseException()"
[disabled]="verifySaving"
>
Raise Exception
</button>
}
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
</div>
</div>
</td>
</tr>
}
<!-- Attachment panel for this voucher. -->
@if (attachRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Attachments — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="db-muted">Nothing attached to this voucher yet.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
}
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
}
</div>
@@ -1,393 +0,0 @@
/**
* 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/.
*/
/* Layout and page chrome come from cashier-shared.scss; only what is specific to the day book
lives here. */
.db-filters {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
/* The search box takes the slack so the two selects keep their natural width. */
&__search {
flex: 1 1 260px;
}
}
.db-note {
margin: 8px 0 12px;
font-size: 11px;
color: #94a3b8;
}
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
.db-side {
display: inline-block;
min-width: 28px;
text-align: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
background: #f1f5f9;
color: #475569;
&--cr {
background: #dcfce7;
color: #15803d;
}
&--dr {
background: #fee2e2;
color: #b91c1c;
}
}
/* A hairline on the leading edge, tinted by side: readable without colouring the whole row,
which would fight the table's own hover state. */
.db-row--cr td:first-child {
box-shadow: inset 3px 0 0 #22c55e;
}
.db-row--dr td:first-child {
box-shadow: inset 3px 0 0 #ef4444;
}
.db-muted {
color: #94a3b8;
font-size: 11px;
}
.db-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f1f5f9;
color: #475569;
&--ok {
background: #dcfce7;
color: #15803d;
}
&--pending {
background: #fef3c7;
color: #b45309;
}
&--rejected {
background: #fee2e2;
color: #b91c1c;
}
}
/* Not defined in cashier-shared.scss, so the day book carries its own. */
.pg-alert {
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 13px;
&--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
}
}
/* Exception summary, above the table: the day's flagged vouchers listed in full so nobody has to
hunt for a badge. */
.db-exceptions {
margin-bottom: 14px;
padding: 12px 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 8px;
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: #b91c1c;
font-size: 13px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
/* Pushed to the right so the count reads first. */
.pg-btn {
margin-left: auto;
height: 30px;
font-size: 12px;
}
}
&__item {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 6px 0;
font-size: 12px;
border-top: 1px dotted #fecaca;
}
&__amt {
font-variant-numeric: tabular-nums;
color: #334155;
}
&__diff {
color: #b91c1c;
}
&__why {
color: #0d1b2a;
font-weight: 600;
}
}
/* The reason spelled out under its own flagged row. */
.db-exc-row td {
background: #fef2f2 !important;
border-bottom: 1px solid #fecaca !important;
color: #7f1d1d;
font-size: 12px;
padding: 8px 14px !important;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
vertical-align: -3px;
margin-right: 4px;
}
}
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
something anyone has to act on. */
.db-exc-row--resolved td {
background: #f0fdf4 !important;
border-bottom: 1px solid #bbf7d0 !important;
color: #14532d;
}
.db-exc-row__resolution {
margin-top: 4px;
padding-top: 4px;
border-top: 1px dotted #bbf7d0;
}
.db-actions {
white-space: nowrap;
}
/* Raising an exception is a deliberate, consequential act — styled to read that way without
looking destructive, since nothing is deleted. */
.pg-btn--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
&:hover {
background: #fef3c7;
}
}
.db-icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #475569;
cursor: pointer;
&:hover {
background: #f1f5f9;
color: #0d1b2a;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
/* Attachment count rides on the paperclip so a voucher with a scanned slip reads as different
at a glance, without a separate column. */
&__count {
position: absolute;
top: -5px;
right: -5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: #2563eb;
color: #fff;
font-size: 9px;
font-weight: 800;
line-height: 14px;
}
}
/* Inline panel, opened under the row it belongs to. */
.db-panel-row td {
background: #f8fafc !important;
padding: 0 !important;
}
.db-panel {
padding: 14px 16px;
border-left: 3px solid #2563eb;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: #0d1b2a;
}
&__body {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
&__remarks {
display: block;
margin-top: 12px;
max-width: 640px;
}
&__warn {
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12px;
}
&__actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
}
/* The two amounts being compared, side by side. */
.db-cmp {
padding: 8px 14px;
border-radius: 6px;
background: #fff;
border: 1px solid #e2e8f0;
&__label {
display: block;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
&__value {
display: block;
margin-top: 2px;
font-size: 15px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0d1b2a;
}
&--good {
background: #f0fdf4;
border-color: #bbf7d0;
.db-cmp__value {
color: #15803d;
}
}
&--bad {
background: #fef2f2;
border-color: #fecaca;
.db-cmp__value {
color: #b91c1c;
}
}
}
.db-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.db-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 6px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
&__name {
font-weight: 700;
color: #0d1b2a;
}
}
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
@@ -1,519 +0,0 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface DayBookRow {
voucherNo: string | null;
side: 'CR' | 'DR';
kind: string;
accountNo: string | null;
accountType: string | null;
party: string | null;
amount: number | null;
paymentTypeName: string | null;
description: string | null;
status: string;
handledBy: string | null;
approvedBy: string | null;
txnId: string | null;
submittedAt: string | null;
actionedAt: string | null;
/** null until someone has physically checked the slip. */
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
slipAmount: number | null;
verifyRemarks: string | null;
verifiedByName: string | null;
verifiedAt: string | null;
/** Set once a clerk or manager has closed the exception; null while it is still open. */
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
attachmentCount: number;
}
/**
* Day book — every voucher raised on one date in a single list: cash deposits, withdrawals,
* disbursements, collections and bank transfers, with the day's credit and debit totals.
*/
@Component({
selector: 'mifosx-day-book',
templateUrl: './day-book.component.html',
styleUrls: [
'../cashier-shared.scss',
'./day-book.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class DayBookComponent implements OnInit {
private service = inject(CashierService);
date = '';
side: '' | 'CR' | 'DR' = '';
status = '';
search = '';
rows: DayBookRow[] = [];
creditTotal = 0;
debitTotal = 0;
netTotal = 0;
loading = false;
loaded = false;
errorMsg = '';
ngOnInit(): void {
this.date = this.today();
this.load();
}
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
load(): void {
if (!this.date) return;
this.loading = true;
this.errorMsg = '';
this.service
.dayBook(this.date, this.side || undefined, this.status || undefined)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day book.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.rows = res?.rows ?? [];
this.creditTotal = Number(res?.creditTotal ?? 0);
this.debitTotal = Number(res?.debitTotal ?? 0);
this.netTotal = Number(res?.netTotal ?? 0);
});
}
/** Shifts the selected date by whole days, so yesterday is one click rather than a date picker. */
shiftDate(days: number): void {
const [
y,
m,
d
] = this.date.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
this.date = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
this.load();
}
goToday(): void {
this.date = this.today();
this.load();
}
/** Show only the flagged vouchers — set from the exception banner. */
exceptionsOnly = false;
/**
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
* visible on its own row as history, but it is no longer something anyone has to act on.
*/
get exceptionRows(): DayBookRow[] {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
}
/** Closed exceptions, kept for the record rather than for action. */
get resolvedExceptionCount(): number {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
}
/** Human label for a resolution code. */
resolutionLabel(status: string | null): string {
switch (status) {
case 'RESOLVED_SYSTEM_ERROR':
return 'System entry was wrong';
case 'RESOLVED_SLIP_ERROR':
return 'Slip was written wrong';
case 'RESOLVED_NO_ACTION':
return 'No action needed';
default:
return status ?? '';
}
}
/** Vouchers nobody has checked against a slip yet. */
get uncheckedCount(): number {
return this.rows.filter((r) => !r.verifyStatus).length;
}
toggleExceptionsOnly(): void {
this.exceptionsOnly = !this.exceptionsOnly;
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
[
r.voucherNo,
r.accountNo,
r.party,
r.kind,
r.paymentTypeName,
r.handledBy,
r.description
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
);
}
/** Totals for what is on screen, so a search or side filter still reconciles. */
get shownCredit(): number {
return this.filteredRows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
get shownDebit(): number {
return this.filteredRows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((s, r) => s + Number(r.amount ?? 0), 0);
}
/** Only approved money is real money — a pending or rejected voucher must not move the totals. */
private countsAsMoney(r: DayBookRow): boolean {
const s = (r.status ?? '').toUpperCase();
return s === 'APPROVED' || s === 'COMPLETED';
}
statusClass(status: string): string {
switch ((status ?? '').toUpperCase()) {
case 'APPROVED':
case 'COMPLETED':
return 'db-badge db-badge--ok';
case 'PENDING':
return 'db-badge db-badge--pending';
case 'REJECTED':
return 'db-badge db-badge--rejected';
default:
return 'db-badge';
}
}
/** Time of day only — the date is already the page's subject. */
timeOf(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
// ── Physical slip verification ────────────────────────────────────────────
/** Row whose verification panel is open, or null when none is. */
verifyRow: DayBookRow | null = null;
verifySlipAmount: number | null = null;
verifyRemarks = '';
verifySaving = false;
openVerify(r: DayBookRow): void {
this.verifyRow = r;
// Pre-filled with the system's own figure: the common case is that the two agree, and when
// they don't the cashier only has to change the digits that actually differ.
this.verifySlipAmount = Number(r.amount ?? 0);
this.verifyRemarks = r.verifyRemarks ?? '';
this.attachRow = null;
}
closeVerify(): void {
this.verifyRow = null;
this.verifySlipAmount = null;
this.verifyRemarks = '';
}
/** True when the amount typed off the slip differs from what the system recorded. */
get verifyMismatch(): boolean {
if (!this.verifyRow || this.verifySlipAmount == null) return false;
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
}
/**
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
*/
get verifySlipBlank(): boolean {
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
}
get verifyDifference(): number {
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
}
/**
* Saves the verdict. The status is derived from the figures rather than picked by the user —
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
*/
saveVerify(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
const isException = this.verifyMismatch;
if (isException && !this.verifyRemarks.trim()) {
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
.verifyVoucher(
row.voucherNo,
isException ? 'EXCEPTION' : 'VERIFIED',
isException ? Number(this.verifySlipAmount) : undefined,
this.verifyRemarks.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
/**
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
* is exactly what an investigation needs.
*/
raiseException(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
if (!this.verifyRemarks.trim()) {
this.errorMsg = 'Say what the problem is before raising an exception.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
// both the slip and the system said at the time the exception was raised.
.verifyVoucher(
row.voucherNo,
'EXCEPTION',
Number(this.verifySlipAmount ?? row.amount ?? 0),
this.verifyRemarks.trim()
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
// ── Attachments ───────────────────────────────────────────────────────────
attachRow: DayBookRow | null = null;
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
openAttach(r: DayBookRow): void {
this.attachRow = r;
this.verifyRow = null;
this.attachments = [];
if (!r.voucherNo) return;
this.attachLoading = true;
this.service
.listVoucherAttachments(r.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
closeAttach(): void {
this.attachRow = null;
this.attachments = [];
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.attachRow;
if (!file || !row?.voucherNo) return;
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
// only to have it refused.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo!,
file.name,
file.type || 'application/octet-stream',
String(reader.result)
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.openAttach(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
/**
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
* then handed to the browser as a normal save.
*/
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
deleteAttachment(a: any): void {
const row = this.attachRow;
this.service
.deleteVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe(() => {
if (row) this.openAttach(row);
this.load();
});
}
exportCsv(): void {
const header = [
'Voucher No',
'Side',
'Type',
'Account',
'Party',
'Amount',
'Payment Type',
'Status',
'Handled By',
'Approved By',
'Slip Check',
'Slip Amount',
'Remarks',
'Checked By',
'Resolution',
'Resolution Notes',
'Closed By',
'Attachments',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.voucherNo ?? '',
r.side,
r.kind,
r.accountNo ?? '',
r.party ?? '',
String(r.amount ?? 0),
r.paymentTypeName ?? '',
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
r.verifyStatus ?? 'NOT CHECKED',
r.slipAmount != null ? String(r.slipAmount) : '',
r.verifyRemarks ?? '',
r.verifiedByName ?? '',
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
r.resolutionNotes ?? '',
r.resolvedByName ?? '',
String(r.attachmentCount ?? 0),
this.timeOf(r.submittedAt)
]
// Quote every field and double any embedded quote: names and narrations routinely
// contain commas, which would otherwise split into extra columns.
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [
header.join(','),
...lines
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `day-book-${this.date}.csv`;
a.click();
URL.revokeObjectURL(url);
}
}
@@ -2317,9 +2317,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
if (isYes(this.kycDetails?.willfulDefaulter))
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
// No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal
// prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it
// risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports.
if (this.kycDetails?.amlCheckStatus === 'FLAGGED')
alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' });
return alerts;
}
@@ -3221,7 +3220,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// No AML prompt on the GL path either — see the note on the savings/current path below.
const AML_GL = 200000;
if (amount > AML_GL && isCash) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly)
);
return;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
@@ -3301,12 +3314,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
// AML monitoring is deliberately invisible here. Every posted transaction is evaluated
// server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and
// anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns.
// The cashier is never prompted or warned at the counter: tipping off the customer defeats
// the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold
// duplicated none of the real rule set anyway.
// 6. AML large cash
const AML_THRESHOLD = 200000;
if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) {
this.showConfirmAlert(
'warning',
'AML Alert — Large Cash Transaction',
[
`Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'This transaction requires manager approval.',
'Have you obtained approval to proceed?'
],
'Yes, Proceed',
() => this.openConfirmDialog(row, amount, isDeposit, selectedPt)
);
return;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPt);
}
@@ -1,183 +0,0 @@
<!--
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="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · EXCEPTIONS</div>
<h1 class="pg-title">Voucher Exceptions</h1>
<p class="pg-sub">
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
document.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Show</span>
<select [(ngModel)]="filter" (change)="load()">
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
<option value="ALL">All</option>
</select>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (!canResolve && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
that whoever raised the flag is not the one who rules on it.
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && exceptions.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
</div>
}
@for (e of exceptions; track e.voucherNo) {
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
<div class="ve-card__head">
<span class="pg-chip">{{ e.voucherNo }}</span>
@if (e.resolutionStatus) {
<span class="db-badge db-badge--ok">RESOLVED</span>
} @else {
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
}
<span class="db-muted">
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
</span>
@if (!e.resolutionStatus && canResolve) {
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
Review &amp; Close
</button>
}
</div>
<div class="ve-card__amts">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
</div>
@if (!amountsAgree(e)) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Written on slip</span>
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
</div>
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp">
<span class="db-cmp__label">Amounts</span>
<span class="db-cmp__value">Agree — flagged for another reason</span>
</div>
}
</div>
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
@if (e.resolutionStatus) {
<div class="ve-card__resolution">
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
</div>
}
<!-- Resolution panel, opened under the exception it closes. -->
@if (resolveRow === e) {
<div class="db-panel ve-panel">
<div class="db-panel__head">
<strong>Close exception — {{ e.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="pg-field ve-panel__field">
<span>What was wrong?</span>
<select [(ngModel)]="resolutionStatus">
<option value="">— Select —</option>
@for (o of resolutionOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="pg-field ve-panel__field">
<span>What was found, and what was done about it?</span>
<input
type="text"
[(ngModel)]="resolutionNotes"
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
/>
</label>
<div class="ve-panel__docs">
<strong>Supporting document</strong>
<p class="db-muted">
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
whatever the decision rests on.
</p>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
</div>
}
</div>
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveResolution()"
[disabled]="!canSubmitResolution"
>
Close Exception
</button>
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
</div>
</div>
}
</div>
}
</div>
@@ -1,120 +0,0 @@
/**
* 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/.
*/
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
shared with the day book. Only the exception card itself is defined here. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
reads at a glance without opening anything. */
.ve-card {
padding: 14px 16px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--resolved {
border-left-color: #16a34a;
background: #fafffb;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
/* Pushed right so the identity of the voucher reads first. */
&__act {
margin-left: auto;
height: 32px;
font-size: 12px;
}
&__amts {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
&__why {
margin: 0;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #0d1b2a;
}
&__resolution {
margin-top: 10px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 12px;
color: #14532d;
}
}
.ve-panel {
margin-top: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
&__field {
display: block;
margin-bottom: 12px;
max-width: 640px;
}
&__docs {
padding: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
p {
margin: 4px 0 10px;
}
}
&__warn {
padding: 6px 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
color: #92400e;
}
}
@@ -1,248 +0,0 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface VoucherException {
voucherNo: string;
systemAmount: number | null;
slipAmount: number | null;
remarks: string | null;
raisedByName: string | null;
raisedAt: string | null;
accountNo: string | null;
party: string | null;
raisedAgainst: string | null;
attachmentCount: number;
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
}
/**
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
*
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
* or manager sits down with them, records what was actually wrong, and attaches the paper that
* supports the decision.
*/
@Component({
selector: 'mifosx-voucher-exceptions',
templateUrl: './voucher-exceptions.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./voucher-exceptions.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherExceptionsComponent implements OnInit {
private service = inject(CashierService);
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
exceptions: VoucherException[] = [];
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
canResolve = false;
loading = false;
loaded = false;
errorMsg = '';
// Resolution panel
resolveRow: VoucherException | null = null;
resolutionStatus = '';
resolutionNotes = '';
resolveSaving = false;
// Attachments for the voucher being resolved
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
readonly resolutionOptions = [
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.voucherExceptions(this.filter)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.exceptions = res?.exceptions ?? [];
this.canResolve = !!res?.canResolve;
});
}
difference(e: VoucherException): number {
if (e.slipAmount == null) return 0;
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
}
/** True when the flag was about something other than the amount. */
amountsAgree(e: VoucherException): boolean {
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
}
openResolve(e: VoucherException): void {
this.resolveRow = e;
this.resolutionStatus = '';
this.resolutionNotes = '';
this.loadAttachments(e);
}
closeResolve(): void {
this.resolveRow = null;
this.attachments = [];
}
private loadAttachments(e: VoucherException): void {
this.attachments = [];
this.attachLoading = true;
this.service
.listVoucherAttachments(e.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.resolveRow;
if (!file || !row) return;
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo,
file.name,
file.type || 'application/octet-stream',
String(reader.result),
'Supporting document for exception resolution'
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.loadAttachments(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
get canSubmitResolution(): boolean {
return (
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
);
}
saveResolution(): void {
const row = this.resolveRow;
if (!row) return;
this.resolveSaving = true;
this.errorMsg = '';
this.service
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.resolveSaving = false;
if (res) {
this.closeResolve();
this.load();
}
});
}
resolutionLabel(status: string | null): string {
if (!status) return '';
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
}
/** Date and time — an exception queue spans days, so the day matters here. */
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
}
+31 -19
View File
@@ -20,29 +20,37 @@ import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
export class DividendClaimRequestService {
private http = inject(HttpClient);
/**
* Records a claim for an unclaimed dividend, collected as a Demand Draft.
*
* A dividend is only ever paid out as a draft — the savings-credit, cash and loan-set-off routes
* this endpoint once accepted are retired, so the payload carries just what a draft needs: how
* much, drawn in whose favour, and payable where.
*/
submit(
lockId: number,
claim: {
ddApplyAmount: number;
ddBeneficiaryName: string;
ddPayableAtBranch: string;
ddPayableAtCity: string;
breakdown: {
loanId?: number;
loanApplyAmount?: number;
savingsAccountId?: number;
savingsApplyAmount?: number;
cashApplyAmount?: number;
tokNo?: string;
}
): Observable<any> {
return this.http.post('/clerk/dividend-claim-requests', {
lockId,
ddApplyAmount: String(claim.ddApplyAmount),
ddBeneficiaryName: claim.ddBeneficiaryName,
ddPayableAtBranch: claim.ddPayableAtBranch,
ddPayableAtCity: claim.ddPayableAtCity
});
const body: any = { lockId };
if (breakdown.loanId != null) {
body.loanId = breakdown.loanId;
}
if (breakdown.loanApplyAmount != null) {
body.loanApplyAmount = String(breakdown.loanApplyAmount);
}
if (breakdown.savingsAccountId != null) {
body.savingsAccountId = breakdown.savingsAccountId;
}
if (breakdown.savingsApplyAmount != null) {
body.savingsApplyAmount = String(breakdown.savingsApplyAmount);
}
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
if (breakdown.tokNo != null) {
body.tokNo = breakdown.tokNo;
}
return this.http.post('/clerk/dividend-claim-requests', body);
}
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
@@ -60,6 +68,10 @@ export class DividendClaimRequestService {
});
}
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
list(status = 'PENDING'): Observable<any> {
return this.http.get('/clerk/dividend-claim-requests', { params: { status } });
}
@@ -200,47 +200,11 @@
<button type="button" class="pg-btn" [disabled]="locksLoading" (click)="loadLocks()">Refresh</button>
</label>
</div>
<!-- Financial-year chips, built from the locks themselves: a new declaration adds its year here
on its own, and a year with nothing left to show stops being offered. Shown even for a single
year, so the filter is visibly there before a second year exists to use it on. -->
@if (lockYears.length > 0) {
<div class="dd-year-filter">
<span class="dd-year-filter__label">Financial year</span>
<div class="dd-filter-group">
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === 'ALL'"
(click)="setLockYearFilter('ALL')"
>
All years
</button>
@for (year of lockYears; track year) {
<button
type="button"
class="dd-filter-btn"
[class.dd-filter-btn--active]="lockYearFilter === year"
(click)="setLockYearFilter(year)"
>
FY {{ year }}
</button>
}
</div>
<span class="dd-year-filter__total">
{{ visibleLocks.length }} lock{{ visibleLocks.length === 1 ? '' : 's' }} ·
{{ visibleLocksTotal | number: '1.2-2' }}
</span>
</div>
}
<table class="pg-table">
<thead>
<tr>
<th>Client</th>
<!-- Which declaration this lock came from. A member can hold several unclaimed dividends at
once — this year's plus an earlier year they never collected — and amount alone cannot
tell them apart. -->
<th>Dividend</th>
<th>Savings Account</th>
<th class="num">Amount</th>
<th>Locked Date</th>
<th>Release Due</th>
@@ -254,23 +218,16 @@
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (locksLoaded && visibleLocks.length === 0) {
} @else if (locksLoaded && locks.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No dividend locks match this filter.</td>
</tr>
} @else {
@for (l of visibleLocks; track l.lockId) {
@for (l of locks; track l.lockId) {
<tr>
<td>{{ l.clientName }}</td>
<td>
@if (l.financialYear) {
<div class="dd-dividend-year">FY {{ l.financialYear }}</div>
<div class="dd-dividend-period">
{{ l.productName }} · {{ l.periodStartDate }} → {{ l.periodEndDate }}
</div>
} @else {
—
}
<span class="pg-chip">{{ l.savingsAccountNo }}</span>
</td>
<td class="num">{{ l.amount | number: '1.2-2' }}</td>
<td>{{ l.lockedDate }}</td>
@@ -292,33 +249,48 @@
<td colspan="8">
<div class="dd-apply-panel dd-apply-panel--wrap">
<div class="dd-apply-panel-title">
Unclaimed amount: {{ l.amount | number: '1.2-2' }}
@if (l.financialYear) {
<span> (FY {{ l.financialYear }} dividend)</span>
}
— issue as a Demand Draft
Unclaimed amount: {{ l.amount | number: '1.2-2' }} — split it below (loan, savings, cash)
</div>
<!-- A dividend is collected only as a Demand Draft. Amount defaults to the full
unclaimed balance; anything left unclaimed stays on the lock for a later visit. -->
@if (claimOverdueLoans[l.lockId] && claimOverdueLoans[l.lockId].length > 0) {
<label class="inv-form-field">
<span>Apply to Overdue Loan</span>
<select [(ngModel)]="claimLoanId[l.lockId]">
@for (loan of claimOverdueLoans[l.lockId]; track loan.loanId) {
<option [ngValue]="loan.loanId">
{{ loan.accountNo }} — Overdue {{ loan.overdueAmount | number: '1.2-2' }}
</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Loan Amount</span>
<input type="number" [(ngModel)]="claimLoanAmount[l.lockId]" min="0" step="0.01" />
</label>
} @else {
<div class="pg-empty-inline">No overdue loan for this client.</div>
}
<label class="inv-form-field">
<span>DD Amount</span>
<input type="number" [(ngModel)]="claimDdAmount[l.lockId]" min="0" step="0.01" />
<span>Credit to Savings</span>
<input type="number" [(ngModel)]="claimSavingsAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (!l.savingsAccountId && claimSavingsAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Savings Account ID (no linked account — enter one to credit)</span>
<input type="number" [(ngModel)]="claimSavingsAccountOverride[l.lockId]" min="1" step="1" />
</label>
}
<label class="inv-form-field">
<span>Beneficiary Name</span>
<input type="text" [(ngModel)]="claimDdBeneficiary[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — Branch</span>
<input type="text" [(ngModel)]="claimDdPayableAtBranch[l.lockId]" />
</label>
<label class="inv-form-field">
<span>Payable At — City</span>
<input type="text" [(ngModel)]="claimDdPayableAtCity[l.lockId]" />
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (claimCashAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Token No. (for cashier hand-over)</span>
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
</label>
}
<div class="dd-apply-panel-total">
Claiming: {{ claimDdAmount[l.lockId] || 0 | number: '1.2-2' }} /
{{ l.amount | number: '1.2-2' }}
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
</div>
@if (claimError) {
<div class="dd-apply-error">{{ claimError }}</div>
@@ -343,21 +315,6 @@
<div class="pg-card" style="margin-top: 16px">
<div class="pos-section-label">Dividend Claim Requests — Pending Review</div>
<!-- The draft just drawn. Approving refreshes the list, so the row it came from is gone by the
time the clerk looks — and the DD number is exactly what they need to write on the
counterfoil and hand over. -->
@if (lastIssuedDd) {
<div class="dd-issued-banner">
<div class="dd-issued-banner__main">
Demand Draft <strong>{{ lastIssuedDd.ddNumber }}</strong> issued to {{ lastIssuedDd.clientName }} for
{{ lastIssuedDd.amount | number: '1.2-2' }}
</div>
<button type="button" class="dd-issued-banner__close" (click)="lastIssuedDd = null" aria-label="Dismiss">
&times;
</button>
</div>
}
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="claimRequestsLoading" (click)="loadClaimRequests()">
@@ -369,44 +326,53 @@
<thead>
<tr>
<th>Client</th>
<!-- A dividend is collected only as a Demand Draft, so what the checker needs to see is the
draft about to be drawn: for how much, in whose favour, payable where. -->
<th class="num">DD Amount</th>
<th>Beneficiary</th>
<th>Payable At</th>
<th>Savings Account</th>
<th class="num">To Loan</th>
<th class="num">To Savings</th>
<th class="num">Cash</th>
<th class="num">Unclaimed Balance</th>
<th>Requested By</th>
<th>Client Loan Status</th>
<th></th>
</tr>
</thead>
<tbody>
@if (claimRequestsLoading) {
<tr>
<td colspan="7" class="pg-empty">Loading…</td>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (claimRequestsLoaded && claimRequests.length === 0) {
<tr>
<td colspan="7" class="pg-empty">No dividend claim requests pending review.</td>
<td colspan="8" class="pg-empty">No dividend claim requests pending review.</td>
</tr>
} @else {
@for (req of claimRequests; track req.id) {
<tr>
<td>{{ req.client_name }}</td>
<td class="num">{{ req.dd_apply_amount | number: '1.2-2' }}</td>
<td>{{ req.dd_beneficiary_name || '—' }}</td>
<td>
@if (req.dd_payable_at_branch || req.dd_payable_at_city) {
{{ req.dd_payable_at_branch }}
@if (req.dd_payable_at_branch && req.dd_payable_at_city) {
,
}
{{ req.dd_payable_at_city }}
} @else {
—
<span class="pg-chip">{{ req.savings_account_no }}</span>
</td>
<td class="num">
{{
req.loan_apply_amount > 0
? (req.loan_apply_amount | number: '1.2-2') + ' (' + req.loan_account_no + ')'
: '—'
}}
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
@if (req.cash_apply_amount > 0 && req.tok_no) {
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
}
</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>{{ req.clerk_name }}</td>
<td>
@if (req.has_overdue_loan) {
<span class="dd-badge dd-badge--failed">Has Overdue EMI</span>
} @else {
<span class="dd-badge dd-badge--posted">No Overdue Loan</span>
}
</td>
<td>
<button type="button" class="pg-btn" [disabled]="claimActioningId === req.id" (click)="approveClaim(req)">
{{ claimActioningId === req.id ? 'Approving…' : 'Approve' }}
@@ -421,7 +387,7 @@
</tr>
@if (expandedRejectClaimId === req.id) {
<tr class="dd-apply-row">
<td colspan="7">
<td colspan="8">
<div class="dd-apply-panel">
<label class="inv-form-field">
<span>Rejection Reason</span>
@@ -169,78 +169,3 @@
color: #fff;
}
}
/* Which declaration a lock belongs to. The financial year leads because that is how a member asks
for it ("my 2026-27 dividend"); the product and exact period sit under it for the clerk who needs
to be certain, without crowding the row. */
.dd-dividend-year {
font-weight: 600;
font-size: 13px;
color: #1e293b;
white-space: nowrap;
}
.dd-dividend-period {
font-size: 11.5px;
color: #64748b;
white-space: nowrap;
}
/* Financial-year chips. Sits below the status chips as a second, narrower axis over the same list —
status is fetched from the server, year filters what came back. */
.dd-year-filter {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 0 16px 12px;
&__label {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__total {
margin-left: auto;
font-size: 12.5px;
color: #475569;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
/* The DD number just drawn. Approving refreshes the pending list, so the row it came from is gone
by the time the clerk reads it — and that number is what goes on the counterfoil. */
.dd-issued-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 16px 0;
padding: 10px 14px;
border: 1px solid #86efac;
border-radius: 6px;
background: #f0fdf4;
color: #166534;
font-size: 13.5px;
&__main {
flex: 1;
}
&__close {
border: none;
background: none;
color: #166534;
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
&:hover {
color: #14532d;
}
}
}
@@ -56,30 +56,19 @@ export class DividendDashboardComponent implements OnInit {
declarationsLoading = false;
declarationsLoaded = false;
/** Every lock the status filter returned, before the year filter narrows it. */
locks: any[] = [];
locksLoading = false;
locksLoaded = false;
lockStatusFilter = 'ALL';
/**
* Financial years that actually have locks, newest first — derived from the data rather than
* listed anywhere, so a new declaration adds its year on its own and a year whose locks are all
* gone stops being offered. No maintenance as years roll over.
*/
lockYears: string[] = [];
lockYearFilter = 'ALL';
expandedClaimLockId: number | null = null;
/**
* A dividend is collected as a Demand Draft and nothing else — the savings-credit, cash and
* loan-set-off routes are retired, so the panel only gathers what a draft needs: how much, drawn
* in whose favour, and payable where.
*/
claimDdAmount: Record<number, number | null> = {};
claimDdBeneficiary: Record<number, string> = {};
claimDdPayableAtBranch: Record<number, string> = {};
claimDdPayableAtCity: Record<number, string> = {};
claimOverdueLoans: Record<number, any[]> = {};
claimLoanId: Record<number, number | null> = {};
claimLoanAmount: Record<number, number | null> = {};
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimTokNo: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
@@ -88,13 +77,6 @@ export class DividendDashboardComponent implements OnInit {
claimRequestsLoaded = false;
claimActioningId: number | null = null;
claimActionError: Record<number, string> = {};
/**
* The Demand Draft just drawn by approving a claim. Held so the clerk can read off the number
* after the row it came from has left the pending list — it is what goes on the counterfoil and
* into the member's hand, and there is nowhere else on this screen to find it.
*/
lastIssuedDd: { ddNumber: string; clientName: string; amount: number } | null = null;
expandedRejectClaimId: number | null = null;
rejectReason: Record<number, string> = {};
@@ -113,45 +95,74 @@ export class DividendDashboardComponent implements OnInit {
}
this.expandedClaimLockId = lock.lockId;
this.claimError = null;
// Defaults to the whole unclaimed balance and the member's own name, which is what a member
// asks for almost every time; the clerk overrides only when the member wants otherwise.
this.claimDdAmount[lock.lockId] = Number(lock.amount);
this.claimDdBeneficiary[lock.lockId] = lock.clientName ?? '';
this.claimDdPayableAtBranch[lock.lockId] = '';
this.claimDdPayableAtCity[lock.lockId] = '';
this.claimLoanId[lock.lockId] = null;
this.claimLoanAmount[lock.lockId] = null;
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimTokNo[lock.lockId] = '';
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
.subscribe((loans: any[]) => {
this.claimOverdueLoans[lock.lockId] = loans ?? [];
if (loans && loans.length > 0) {
this.claimLoanId[lock.lockId] = loans[0].loanId;
const loanAmt = Math.min(Number(lock.amount), Number(loans[0].overdueAmount));
this.claimLoanAmount[lock.lockId] = loanAmt;
this.claimSavingsAmount[lock.lockId] = Number((Number(lock.amount) - loanAmt).toFixed(2));
}
});
}
claimTotal(lock: any): number {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
);
}
submitClaim(lock: any): void {
const ddAmount = Number(this.claimDdAmount[lock.lockId] || 0);
const beneficiary = this.claimDdBeneficiary[lock.lockId]?.trim();
const payableAtBranch = this.claimDdPayableAtBranch[lock.lockId]?.trim();
const payableAtCity = this.claimDdPayableAtCity[lock.lockId]?.trim();
if (ddAmount <= 0) {
this.claimError = 'Enter a DD amount greater than zero.';
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
// dividend share often works out to paise — floor to the nearest rupee so the denomination
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
return;
}
if (ddAmount > Number(lock.amount)) {
this.claimError = `DD amount (${ddAmount}) cannot exceed the unclaimed amount of ${lock.amount}.`;
if (total > Number(lock.amount)) {
this.claimError = `Total (${total}) cannot exceed the unclaimed amount of ${lock.amount}.`;
return;
}
if (!beneficiary) {
this.claimError = 'Enter the beneficiary name — a Demand Draft cannot be drawn without a payee.';
if (loanAmount > 0 && !this.claimLoanId[lock.lockId]) {
this.claimError = 'Select a loan to apply the loan amount towards.';
return;
}
if (!payableAtBranch || !payableAtCity) {
this.claimError = 'Enter both the branch and city the Demand Draft is payable at.';
if (savingsAmount > 0 && !lock.savingsAccountId && !this.claimSavingsAccountOverride[lock.lockId]) {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
const tokNo = this.claimTokNo[lock.lockId]?.trim();
if (cashAmount > 0 && !tokNo) {
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
.submit(lock.lockId, {
ddApplyAmount: ddAmount,
ddBeneficiaryName: beneficiary,
ddPayableAtBranch: payableAtBranch,
ddPayableAtCity: payableAtCity
loanId: loanAmount > 0 ? this.claimLoanId[lock.lockId] : undefined,
loanApplyAmount: loanAmount > 0 ? loanAmount : undefined,
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
tokNo: cashAmount > 0 ? tokNo : undefined
})
.subscribe({
next: () => {
@@ -182,18 +193,8 @@ export class DividendDashboardComponent implements OnInit {
this.claimActioningId = req.id;
delete this.claimActionError[req.id];
this.claimRequestService.approve(req.id).subscribe({
next: (res: any) => {
next: () => {
this.claimActioningId = null;
// The draft number is the one thing the clerk needs off this action — it is what they write
// on the counterfoil and hand to the member. Approving refreshes the list, so the row this
// came from disappears; without surfacing it here the number would be lost.
if (res?.ddNumber) {
this.lastIssuedDd = {
ddNumber: res.ddNumber,
clientName: req.client_name,
amount: Number(res.totalDisbursed) || Number(req.dd_apply_amount) || 0
};
}
this.loadClaimRequests();
this.loadLocks();
this.loadSummary();
@@ -268,49 +269,16 @@ export class DividendDashboardComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((res: any[]) => {
this.locks = res ?? [];
this.rebuildLockYears();
this.locksLoading = false;
this.locksLoaded = true;
});
}
/**
* Rebuilds the year chips from whatever locks came back, newest first.
*
* A year the current status filter has no locks for disappears from the chips, so the selected
* year is reset to ALL when that happens — otherwise the table would show nothing with no
* indication why.
*/
private rebuildLockYears(): void {
this.lockYears = [...new Set(this.locks.map((l) => l.financialYear).filter((y) => !!y))].sort((a, b) =>
b.localeCompare(a)
);
if (this.lockYearFilter !== 'ALL' && !this.lockYears.includes(this.lockYearFilter)) {
this.lockYearFilter = 'ALL';
}
}
/** Locks the table actually renders — the status filter is applied server-side, the year here. */
get visibleLocks(): any[] {
return this.lockYearFilter === 'ALL'
? this.locks
: this.locks.filter((l) => l.financialYear === this.lockYearFilter);
}
/** Total of what is on screen, so a year's worth of dividend can be read off directly. */
get visibleLocksTotal(): number {
return this.visibleLocks.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
}
setLockStatusFilter(status: string): void {
this.lockStatusFilter = status;
this.loadLocks();
}
setLockYearFilter(year: string): void {
this.lockYearFilter = year;
}
lockStatusClass(status: string): string {
switch (status) {
case 'UNCLAIMED':
@@ -435,17 +435,12 @@
@if (!showClosedFixedAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'open' : 'isFixed'">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isFixed'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -529,20 +524,12 @@
<!-- Closed Fixed Deposit Accounts -->
@if (showClosedFixedAccounts) {
<div class="table-wrapper">
<table
mat-table
[dataSource]="fixedDepositAccountsForDisplay | accountsFilter: 'saving' : 'closed' : 'isFixed'"
>
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isFixed'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number
accountNo="{{ element.fdAccountNumber || element.accountNo }}"
></mifosx-account-number>
@if (element.renewalCycleNo) {
<span class="fd-renewal-badge">Renewal {{ element.renewalCycleNo }}</span>
}
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
@@ -209,20 +209,6 @@ $red: #d93025;
gap: 0;
}
// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number
// rather than this term's own account number.
.fd-renewal-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background-color: $gray-200;
color: $gray-700;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
// ── Table Wrapper ──
.table-wrapper {
border-radius: 0 0 8px 8px;
@@ -234,57 +234,7 @@ export class GeneralTabComponent implements OnDestroy {
}
get fixedDepositCount(): number {
return this.fixedDepositAccountsForDisplay.filter(
(a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit'
).length;
}
/**
* Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away.
*
* An FD renewal closes one account and opens the next term as its own account row, so a renewed FD
* would otherwise appear twice on this page -- the matured term under Closed and the new term under
* Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later
* cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its
* current term. A chain that has genuinely ended keeps its final term, so it still lists as closed.
*
* Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched.
*/
get fixedDepositAccountsForDisplay(): any[] {
// Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so
// returning a freshly built array on every change-detection pass would make the table re-bind and
// re-render its rows on every tick -- mouse moves included.
if (this.fdDisplayCacheSource !== this.savingAccounts) {
this.fdDisplayCacheSource = this.savingAccounts;
this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts);
}
return this.fdDisplayCache;
}
private fdDisplayCacheSource: any[] = null;
private fdDisplayCache: any[] = [];
private foldSupersededFdCycles(accounts: any[]): any[] {
const latestCycleByFd = new Map<string, number>();
for (const account of accounts) {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
continue;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) {
latestCycleByFd.set(fdNumber, cycle);
}
}
return accounts.filter((account: any) => {
if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') {
return true;
}
const fdNumber = account.fdAccountNumber ?? account.accountNo;
const cycle = account.renewalCycleNo ?? 0;
return cycle === latestCycleByFd.get(fdNumber);
});
return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length;
}
get recurringDepositCount(): number {
+1 -15
View File
@@ -47,12 +47,7 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
private handleError(response: HttpErrorResponse, request: HttpRequest<any>): Observable<HttpEvent<any>> {
const status = response.status;
const errorBody = response.error;
// defaultUserMessage first: it is the sentence written for the person reading it, while
// developerMessage is for the log. Falling back to response.message last, since that is
// Angular's own "Http failure response for <url>: 409 Conflict" — a description of the
// transport, not of what went wrong, and useless to whoever is looking at the dialog.
let errorMessage =
errorBody?.defaultUserMessage || errorBody?.developerMessage || errorBody?.message || response.message;
let errorMessage = errorBody?.developerMessage || response.message;
if (errorBody?.errors?.[0]) {
errorMessage = errorBody.errors[0].defaultUserMessage || errorBody.errors[0].developerMessage;
}
@@ -109,15 +104,6 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
type: this.translate.instant('error.resource.not.found'),
message: errorMessage || 'Resource does not exist!'
};
} else if (status === 409) {
// A 409 is not a failure of permission or of the request itself — the action is simply not
// allowed in the record's current state (already approved, maker cannot check their own work,
// a guarantee still outstanding). The server always explains which, so its message is shown
// as-is under a title that does not contradict it.
alert = {
type: 'Action Not Allowed',
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
} else if (status === 501) {
+7 -78
View File
@@ -173,20 +173,6 @@
Today's Activity
</a>
}
<!-- Outside the clerkAccountLoaded block on purpose: an open exception has to be
reachable whether or not the clerk currently has an account loaded. -->
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-exceptions')"
[routerLink]="['/cashier/voucher-exceptions']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Exceptions
</a>
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
} @else {
@@ -248,20 +234,6 @@
>
<fa-icon icon="history"></fa-icon> Cash History
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/day-book']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="book"></fa-icon> Day Book
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-exceptions']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
@@ -458,13 +430,14 @@
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clerkMenu" *mifosxHasPermission="'MENU_CLERK'">
<a
class="ni"
tabindex="0"
routerLink="/clerk/dashboard"
(click)="hideShellLayout = true"
*mifosxHasPermission="'MENU_CLERK'"
>
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Clerk
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="branchOpsMenu" *mifosxHasPermission="'MENU_BRANCH_OPS'">
<fa-icon icon="building" class="ni-ico"></fa-icon>Branch Ops
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="clearingMenu" *mifosxHasPermission="'MENU_CLEARING'">
<fa-icon icon="exchange" class="ni-ico"></fa-icon>Clearing
@@ -609,9 +582,6 @@
</mat-menu>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
@@ -1023,41 +993,6 @@
}
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
</mat-menu>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> My Disbursement Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</mat-menu>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
@@ -1092,12 +1027,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
@@ -1,191 +0,0 @@
<!--
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="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
</div>
</div>
<div mat-dialog-content class="pmt__content">
@if (loadingTemplate) {
<div class="pmt__loading">
<mat-spinner diameter="36"></mat-spinner>
</div>
} @else if (loadFailed) {
<div class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not load this account's payment options. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="paymentForm">
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pmt__option"
role="radio"
[attr.aria-checked]="paymentForm.get('onAccountClosureId').value === closureOption.id"
[class.pmt__option--active]="paymentForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pmt__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pmt__option-text">
<span class="pmt__option-title">{{ closureOption.value }}</span>
<span class="pmt__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (paymentForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pmt__option-check"></fa-icon>
}
</button>
}
</div>
@if (
paymentForm.controls.onAccountClosureId.hasError('required') &&
paymentForm.controls.onAccountClosureId.touched
) {
<div class="pmt__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (isPartialReinvest) {
<div class="pmt__split">
<mat-form-field appearance="outline" class="pmt__split-input">
<mat-label>{{ 'labels.inputs.Reinvest Amount' | translate }}</mat-label>
<input type="number" matInput required formControlName="reinvestAmount" />
@if (paymentForm.controls.reinvestAmount?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('min')) {
<mat-error>{{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }}</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) {
<mat-error>{{
'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate
}}</mat-error>
}
</mat-form-field>
<span class="pmt__split-arrow"><fa-icon icon="arrow-right"></fa-icon></span>
<div class="pmt__split-remainder">
<span class="pmt__split-remainder-label">{{ 'labels.inputs.Remainder to Transfer' | translate }}</span>
<span class="pmt__split-remainder-value">{{
remainderAmount !== null ? (remainderAmount | formatNumber) : '—'
}}</span>
</div>
</div>
@if (remainderIsApproximate) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
The selected date is before maturity. Interest up to that date is posted first, so the amount actually
transferred will be at least the figure shown.
</p>
}
}
@if (noDestinationAccounts) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that keeps
the funds in this deposit.
</p>
}
@if (paymentForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (paymentForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (paymentForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (submitError) {
<p class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
color="primary"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -1,296 +0,0 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
.pmt {
display: flex;
flex-direction: column;
min-height: 320px;
max-height: 85vh;
overflow: hidden;
margin: -24px;
}
.pmt__header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 22px 28px 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
color: #fff;
flex: 0 0 auto;
}
.pmt__header-ico {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.pmt__title {
margin: 0;
font-size: 19px;
font-weight: 700;
}
.pmt__subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
line-height: 1.4;
}
.pmt__content {
padding: 22px 28px 8px !important;
overflow-y: auto;
flex: 1 1 auto;
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 18px;
}
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
.pmt__date {
width: 100%;
}
.pmt__section-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
}
.pmt__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 10px;
text-align: left;
background: #fff;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
font: inherit;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
.pmt__option-ico {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
background: $soft;
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
margin: 4px 0 14px;
}
.pmt__error {
display: flex;
align-items: flex-start;
gap: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
line-height: 1.45;
padding: 10px 14px;
margin: 4px 0 8px;
}
.pmt__hint {
display: flex;
align-items: flex-start;
gap: 4px;
background: $soft;
border: 1px solid $border;
border-radius: 8px;
color: $slate;
font-size: 12px;
line-height: 1.45;
padding: 9px 13px;
margin: 0 0 10px;
}
.pmt__hint--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.pmt__split {
display: flex;
align-items: center;
gap: 12px;
background: $soft;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 14px 16px 0;
margin: 4px 0 8px;
flex-wrap: wrap;
}
.pmt__split-input {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
display: flex;
flex-direction: column;
gap: 2px;
background: $green-soft;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 14px;
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-remainder-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $green;
}
.pmt__split-remainder-value {
font-size: 16px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
}
.pmt__content mat-form-field {
width: 100%;
}
.pmt__actions {
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
}
@@ -1,288 +0,0 @@
/**
* 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 {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms';
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
* entered amount renews on this same account, the rest transfers to a savings account picked here.
*/
@Component({
selector: 'mifosx-payment-dialog',
templateUrl: './payment-dialog.component.html',
styleUrls: ['./payment-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
CdkTextareaAutosize,
MatProgressSpinnerModule,
FaIconComponent,
FormatNumberPipe
]
})
export class PaymentDialogComponent implements OnInit {
/** The `onAccountClosureId` codes each closure type is registered under on the backend. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly REINVEST_FULL_ID = 300;
private static readonly REINVEST_PRINCIPAL_ID = 400;
private static readonly PARTIAL_REINVEST_ID = 500;
private static readonly ACTION_ICONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number',
[PaymentDialogComponent.REINVEST_PRINCIPAL_ID]:
'Renew only the principal; interest transferred to a savings account',
[PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest'
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: number = null;
loadingTemplate = true;
loadFailed = false;
submitting = false;
submitError: string = null;
maxDate: Date;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
],
onAccountClosureId: [
'',
Validators.required
],
note: ['']
});
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
this.paymentForm
.get('closedOnDate')
.valueChanges.subscribe(() =>
this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false })
);
this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({
next: (response: any) => {
// Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a
// staff-triggered Payment that may run well before or after the actual counter transaction --
// Transfer to Savings or a Reinvest option covers every real case instead.
this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter(
(option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID
);
this.savingsAccountsData = response.savingsAccounts ?? [];
this.maturityAmount = response.maturityAmount;
this.loadingTemplate = false;
},
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out
// and no indication of why the form never appeared.
error: () => {
this.loadingTemplate = false;
this.loadFailed = true;
}
});
}
/**
* The amount being split, and the bound the entered partial amount is checked against.
* <p>
* The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure
* matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the
* maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current
* balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept
* an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger.
*/
get reinvestCap(): number | null {
if (this.processingOnOrAfterMaturity) {
return this.maturityAmount ?? this.data.accountBalance ?? null;
}
return this.data.accountBalance ?? this.maturityAmount ?? null;
}
/** Whether the chosen closure date is on or after the deposit's maturity date. */
private get processingOnOrAfterMaturity(): boolean {
const chosen = this.paymentForm?.get('closedOnDate')?.value;
if (!chosen || !this.data.maturityDate) {
return false;
}
return this.toTime(chosen) >= this.toTime(this.data.maturityDate);
}
/** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */
private toTime(value: any): number {
if (value instanceof Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
/** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */
get remainderIsApproximate(): boolean {
return !this.processingOnOrAfterMaturity;
}
iconFor(id: number): string {
return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle';
}
descriptionFor(id: number): string {
return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
}
selectClosureType(id: number): void {
this.paymentForm.get('onAccountClosureId').setValue(id);
this.paymentForm.get('onAccountClosureId').markAsTouched();
}
/** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
const needsDestinationAccount =
id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID ||
id === PaymentDialogComponent.PARTIAL_REINVEST_ID ||
id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID;
if (needsDestinationAccount) {
if (!this.paymentForm.contains('toSavingsAccountId')) {
this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.paymentForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
this.paymentForm.removeControl('toSavingsAccountId');
this.paymentForm.removeControl('transferDescription');
}
if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) {
if (!this.paymentForm.contains('reinvestAmount')) {
this.paymentForm.addControl(
'reinvestAmount',
new UntypedFormControl('', [
Validators.required,
Validators.min(0.01),
this.belowMaturityAmountValidator()
])
);
}
} else {
this.paymentForm.removeControl('reinvestAmount');
}
}
/** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */
private belowMaturityAmountValidator(): ValidatorFn {
return (control) => {
const cap = Number(this.reinvestCap);
if (!cap || control.value === null || control.value === '') {
return null;
}
return Number(control.value) >= cap ? { belowMaturityAmount: true } : null;
};
}
get isPartialReinvest(): boolean {
return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID;
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
!this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0
);
}
/** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */
get remainderAmount(): number | null {
const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value;
if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) {
return null;
}
return Number(this.reinvestCap) - Number(reinvestAmount);
}
submit(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.closedOnDate instanceof Date) {
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
}
const data = { ...formValue, dateFormat, locale };
this.submitting = true;
this.submitError = null;
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'This payment could not be processed. Please review the details and try again.';
}
});
}
}
@@ -37,7 +37,7 @@
<span class="fd-hero__meta-item">
<span class="fd-hero__meta-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mifosx-account-number
accountNo="{{ fixedDepositsAccountData.fdAccountNumber || fixedDepositsAccountData.accountNo }}"
accountNo="{{ fixedDepositsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
@@ -230,19 +230,6 @@
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
}
@if (fixedDepositsAccountData.renewalHistory?.length) {
<a
mat-tab-link
class="fd-tab"
[routerLink]="['./renewal-history']"
routerLinkActive
#renewalHistory="routerLinkActive"
[active]="renewalHistory.isActive"
>
<fa-icon icon="history" class="fd-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Renewal History' | translate }}</span>
</a>
}
<a
mat-tab-link
class="fd-tab"
@@ -16,7 +16,6 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
@@ -100,7 +99,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
Payment: 'transact',
'Premature Close': 'danger',
'Undo Activation': 'admin',
Close: 'danger',
@@ -124,7 +122,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'percentage',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
Payment: 'sync-alt',
'Premature Close': 'times-circle',
'Undo Activation': 'undo',
Close: 'times-circle',
@@ -264,35 +261,9 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateFixedDepositsAccount();
break;
case 'Payment':
this.openPaymentDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
// The server bounds a partial reinvest by the balance AFTER it posts interest to the chosen date,
// so the dialog needs both figures plus the maturity date to know which one that will be.
accountBalance: this.fixedDepositsAccountData.summary?.accountBalance,
maturityDate: this.fixedDepositsAccountData.maturityDate
}
});
paymentDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -116,9 +116,6 @@ export class FixedDepositsButtonsConfiguration {
},
{
name: 'Add Charge'
},
{
name: 'Payment'
}
];
break;
@@ -83,22 +83,6 @@
}}</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedFromAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested From' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedFromLink">{{ fixedDepositsAccountData.reinvestedFromAccountNo }}</a>
</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedIntoAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested Into' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedIntoLink">{{ fixedDepositsAccountData.reinvestedIntoAccountNo }}</a>
</span>
</div>
}
</div>
</div>
@@ -48,25 +48,4 @@ export class GeneralTabComponent {
this.entityType = 'Center';
}
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
get reinvestedFromLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedFromAccountId;
return id ? this.siblingAccountLink(id) : null;
}
get reinvestedIntoLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId;
return id ? this.siblingAccountLink(id) : null;
}
}
@@ -36,17 +36,6 @@
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.annualInterestRate | formatNumber }} %</td>
</ng-container>
<ng-container matColumnDef="seniorCitizen">
<th mat-header-cell *matHeaderCellDef><fa-icon icon="user-shield" class="m-r-5"></fa-icon>Senior Citizen</th>
<td mat-cell *matCellDef="let chartSlab">
{{
chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined
? (chartSlab.seniorCitizenRate | formatNumber) + ' %'
: '-'
}}
</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Description' | translate }}</th>
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.description }}</td>
@@ -71,7 +71,6 @@ export class InterestRateChartTabComponent {
'period',
'amountRange',
'interest',
'seniorCitizen',
'description',
'actions'
];
@@ -1,139 +0,0 @@
<!--
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="rh-container">
@if (renewalHistory.length) {
<!-- ─── Summary strip ──────────────────────────────────────────── -->
<div class="rh-summary">
<div class="rh-summary__item">
<span class="rh-summary__label">FD Number</span>
<span class="rh-summary__value">{{ fdNumber }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Terms So Far</span>
<span class="rh-summary__value">{{ renewalHistory.length }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Original Principal</span>
<span class="rh-summary__value">{{ originalPrincipal | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--highlight">
<span class="rh-summary__label">Current Value</span>
<span class="rh-summary__value">{{ currentValue | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--positive">
<span class="rh-summary__label">Lifetime Interest</span>
<span class="rh-summary__value">{{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }}</span>
</div>
</div>
<!-- ─── Vertical timeline ──────────────────────────────────────── -->
<div class="rh-timeline">
@for (
term of renewalHistory;
track term.historyid ?? term.accountid + '-' + term.renewalcycleno;
let last = $last
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__pulse"></span>
}
</span>
@if (!last) {
<span class="rh-node__line"></span>
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
<fa-icon [icon]="renewalTypeIcon(term)"></fa-icon>
{{ renewalTypeLabel(term) }}
</span>
}
</div>
<button type="button" class="rh-chart-btn" (click)="openRateChart(term)">
<fa-icon icon="table"></fa-icon>
View Rate Chart
</button>
</div>
<div class="rh-card__period">
<fa-icon icon="calendar-alt" class="rh-card__period-ico"></fa-icon>
<span>{{ term.activatedondate | dateFormat }}</span>
<fa-icon icon="arrow-right" class="rh-card__period-arrow"></fa-icon>
<span>{{ term.maturitydate | dateFormat }}</span>
@if (tenureLabel(term)) {
<span class="rh-card__tenure">({{ tenureLabel(term) }})</span>
}
</div>
<div class="rh-stats">
<div class="rh-stat">
<span class="rh-stat__label">Principal</span>
<span class="rh-stat__value">{{ term.depositamount | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">Rate</span>
<span class="rh-stat__value">{{ term.interestrate }}%</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: currency.code : 'symbol-narrow')
}}
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
</div>
</div>
<div class="rh-card__foot">
<fa-icon icon="info-circle" class="rh-card__foot-ico"></fa-icon>
<span>
Ledger account:
@if (term.accountid === fixedDepositsAccountData.id) {
<strong>{{ term.accountno }}</strong>
} @else {
<a [routerLink]="siblingLink(term.accountid)">{{ term.accountno }}</a>
}
</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="rh-empty">
<fa-icon icon="history" class="rh-empty__icon"></fa-icon>
<p class="rh-empty__text">{{ 'labels.messages.No Renewal History Available' | translate }}</p>
</div>
}
</div>
@@ -1,364 +0,0 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #2563eb;
$accent-soft: #eff6ff;
.rh-container {
max-width: 860px;
margin: 0 auto;
padding: 4px 0 24px;
}
/* ─── Summary strip ─────────────────────────────────────────────────── */
.rh-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
background: $border;
border: 1px solid $border;
border-radius: 14px;
overflow: hidden;
margin-bottom: 28px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&__item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: #fff;
}
&__label {
font-size: 10.5px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.04em;
}
&__value {
font-size: 15px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
}
&__item--highlight {
background: $accent-soft;
.rh-summary__value {
color: $accent;
}
}
&__item--positive {
background: $positive-soft;
.rh-summary__value {
color: $positive;
}
}
}
@media (width <= 720px) {
.rh-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ─── Timeline rail ──────────────────────────────────────────────────── */
.rh-timeline {
display: flex;
flex-direction: column;
}
.rh-node {
display: flex;
gap: 18px;
}
.rh-node__rail {
flex: none;
width: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.rh-node__dot {
position: relative;
width: 16px;
height: 16px;
margin-top: 22px;
border-radius: 50%;
background: #cbd5e1;
border: 3px solid #fff;
box-shadow: 0 0 0 2px #cbd5e1;
flex: none;
&--active {
background: $positive;
box-shadow: 0 0 0 2px $positive;
}
}
.rh-node__pulse {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid $positive;
opacity: 0.6;
animation: rh-pulse 1.8s ease-out infinite;
}
@keyframes rh-pulse {
0% {
transform: scale(0.6);
opacity: 0.7;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
.rh-node__line {
flex: 1;
width: 2px;
min-height: 24px;
background: $border;
margin: 4px 0;
}
/* ─── Term card ──────────────────────────────────────────────────────── */
.rh-card {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 16px 18px;
margin-bottom: 20px;
transition: box-shadow 0.15s ease;
&--active {
border-color: rgb(4 120 87 / 20%);
box-shadow: 0 2px 10px rgb(4 120 87 / 8%);
}
&:hover {
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
}
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
&__title-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: $navy;
}
&__period {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12.5px;
color: $slate;
font-weight: 500;
&-ico {
color: $muted;
font-size: 11px;
}
&-arrow {
color: $muted;
font-size: 10px;
}
}
&__tenure {
color: $muted;
font-weight: 400;
}
&__foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed $border;
font-size: 11.5px;
color: $muted;
&-ico {
font-size: 10px;
}
a {
color: $accent;
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
/* ─── Status badge / auto-renewed tag ───────────────────────────────── */
.rh-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $soft;
color: $muted;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
&--active {
background: $positive-soft;
color: $positive;
}
}
.rh-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: $accent-soft;
color: $accent;
font-size: 10.5px;
font-weight: 600;
}
/* ─── View Rate Chart button ─────────────────────────────────────────── */
.rh-chart-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border: 1px solid $border;
border-radius: 999px;
background: #fff;
color: $accent;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background: $accent-soft;
border-color: $accent;
}
}
/* ─── Stat grid ──────────────────────────────────────────────────────── */
.rh-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
@media (width <= 640px) {
.rh-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.rh-stat {
display: flex;
flex-direction: column;
gap: 3px;
padding: 9px 12px;
background: $soft;
border-radius: 10px;
&__label {
font-size: 10px;
font-weight: 600;
color: $muted;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&__value {
font-size: 13.5px;
font-weight: 700;
color: $navy;
font-variant-numeric: tabular-nums;
&--positive {
color: $positive;
}
}
&--highlight {
background: $accent-soft;
.rh-stat__value {
color: $accent;
}
}
}
/* ─── Empty state ────────────────────────────────────────────────────── */
.rh-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 64px 16px;
color: $muted;
&__icon {
font-size: 30px;
opacity: 0.4;
}
&__text {
margin: 0;
font-size: 13px;
}
}
@@ -1,235 +0,0 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { Currency } from 'app/shared/models/general.model';
import { CurrencyPipe } from '@angular/common';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductsService } from 'app/products/products.service';
import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component';
/**
* Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab
* rather than a card on General, since a long-lived FD can carry many terms and each one's full detail
* (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably.
*/
@Component({
selector: 'mifosx-renewal-history-tab',
templateUrl: './renewal-history-tab.component.html',
styleUrls: ['./renewal-history-tab.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
CurrencyPipe,
DateFormatPipe
]
})
export class RenewalHistoryTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
fixedDepositsAccountData: any;
currency: Currency;
/**
* The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once
* from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the
* premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history
* carries a senior-citizen column, since that concept only exists on the base rate master.
*/
private activeBaseSlabs: any[] = [];
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.fixedDepositsAccountData = data.fixedDepositsAccountData;
this.currency = this.fixedDepositsAccountData.currency;
});
}
ngOnInit(): void {
const fdType = this.fixedDepositsAccountData?.fdType;
if (!fdType) {
return;
}
this.productsService.getFdBaseInterestRates(fdType).subscribe({
next: (rates: any[]) => {
const active = (rates || []).find((r: any) => r.isActive);
this.activeBaseSlabs = active?.slabs ?? [];
},
error: () => {}
});
}
/**
* The published senior-citizen rate for a tenure band, matched by period type and range against the active
* base rate card. Returns null when nothing matches (no published base version for this FD type, or this
* particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank
* rather than a misleading zero.
*/
private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null {
const match = this.activeBaseSlabs.find(
(s) =>
s.periodTypeEnum === periodTypeEnum &&
s.fromPeriod === fromPeriod &&
(s.toPeriod ?? null) === (toPeriod ?? null)
);
return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null;
}
/**
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
* counterpart account's view page sits at the same route -- only the account-id segment differs.
*/
private siblingAccountLink(targetAccountId: number): string | null {
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
const idx = segments.indexOf('fixed-deposits-accounts');
if (idx === -1) return null;
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
}
siblingLink(targetAccountId: number): string | null {
return this.siblingAccountLink(targetAccountId);
}
/**
* Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they
* arrive straight from the SQL result-set column labels.
*/
get renewalHistory(): any[] {
return this.fixedDepositsAccountData?.renewalHistory ?? [];
}
get fdNumber(): string {
return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo;
}
/** The very first term's opening principal -- what the customer originally deposited. */
get originalPrincipal(): number | null {
return this.renewalHistory[0]?.depositamount ?? null;
}
/** The latest term's settled or projected value -- what the FD is worth right now. */
get currentValue(): number | null {
const latest = this.renewalHistory[this.renewalHistory.length - 1];
return latest ? this.maturityFor(latest) : null;
}
/**
* Total interest earned across every term, settled terms plus the running term's interest-to-date. Null
* only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero".
*/
get lifetimeInterestEarned(): number | null {
if (!this.renewalHistory.length) {
return null;
}
let total = 0;
for (const term of this.renewalHistory) {
if (term.interestearned != null) {
total += Number(term.interestearned);
} else {
const projected = this.maturityFor(term);
if (projected != null && term.depositamount != null) {
total += Number(projected) - Number(term.depositamount);
}
}
}
return total;
}
/**
* A term's settled maturity amount, or its projection while it is still running. Resolved here rather
* than inline so the choice isn't left to operator precedence between `??` and the currency pipe.
*/
maturityFor(term: any): number | null {
return term?.maturedamount ?? term?.projectedmaturityamount ?? null;
}
/** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */
private static readonly PERIOD_TYPE_LABELS: Record<number, string> = {
0: 'Days',
1: 'Weeks',
2: 'Months',
3: 'Years'
};
/** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */
tenureLabel(term: any): string {
if (term?.tenure == null) {
return '';
}
const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? '';
return unit ? `${term.tenure} ${unit}` : `${term.tenure}`;
}
/** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */
renewalTypeLabel(term: any): string | null {
if (term?.renewaltype === 'AUTO') return 'Auto-Renewed';
if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff';
return null;
}
renewalTypeIcon(term: any): string {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
* A term's chart is only archived once it closes via an in-place renewal, so the term still running has no
* archived rows -- for that one term (recognisable as the one matching this account's own id), the
* account's own live chart is shown instead, since it is the same data. The two sources use different key
* casing and represent the period type differently (a raw enum int here, an expanded {value} object on the
* live chart), which this maps into one consistent shape the dialog can render without knowing which source
* it came from.
*/
private chartSlabsFor(term: any): any[] {
const raw = term?.chartSlabs?.length
? term.chartSlabs
: term?.accountid === this.fixedDepositsAccountData?.id
? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? [])
: [];
return raw.map((slab: any) => {
const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum;
const fromPeriod = slab.fromperiod ?? slab.fromPeriod;
const toPeriod = slab.toperiod ?? slab.toPeriod;
// The archived/live row's own senior_citizen_rate is the true figure as published when this term
// was actually priced -- preferred whenever present. The live lookup against today's base rate
// card is only a fallback, for the currently-running term (not archived yet) or an older archive
// row written before that column existed -- flagged as such so the dialog can say so.
const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null;
return {
description: slab.description,
fromPeriod,
toPeriod,
periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '',
annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate,
seniorCitizenRate:
persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null),
seniorCitizenRateIsLive: persistedSeniorRate == null
};
});
}
/** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */
openRateChart(term: any): void {
this.dialog.open(TermRateChartDialogComponent, {
width: '560px',
data: {
term,
slabs: this.chartSlabsFor(term),
isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen
}
});
}
}
@@ -1,77 +0,0 @@
<!--
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="trc-dialog">
<h2 mat-dialog-title class="trc-header">
<span class="trc-header__icon"><fa-icon icon="table"></fa-icon></span>
<span class="trc-header__text">
<span class="trc-header__title">{{ termLabel }} — Rate Chart</span>
<span class="trc-header__sub"
>{{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }}</span
>
</span>
</h2>
<div mat-dialog-content class="trc-content">
@if (slabs.length) {
<table class="trc-table">
<thead>
<tr>
<th>Description</th>
<th>Tenure</th>
<th class="trc-table__rate-col">Rate</th>
<th class="trc-table__rate-col">
<fa-icon icon="user-shield" class="trc-table__sc-ico"></fa-icon>
Senior Citizen
</th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track $index) {
<tr [class.trc-table__row--applied]="isAppliedSlab(slab) || isAppliedSeniorSlab(slab)">
<td>{{ slab.description }}</td>
<td>{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }}</td>
<td class="trc-table__rate-col">
{{ slab.annualInterestRate }}%
@if (isAppliedSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
<td class="trc-table__rate-col trc-table__sc-col">
{{
slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined
? slab.seniorCitizenRate + '%'
: '—'
}}
@if (isAppliedSeniorSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
</tr>
}
</tbody>
</table>
@if (hasLiveSeniorRate) {
<p class="trc-note">
<fa-icon icon="info-circle"></fa-icon>
Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for
reference.
</p>
}
} @else {
<div class="trc-empty">
<fa-icon icon="info-circle" class="trc-empty__icon"></fa-icon>
<p>No rate chart was archived for this term.</p>
</div>
}
</div>
<div mat-dialog-actions align="end" class="trc-actions">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</div>
</div>
@@ -1,159 +0,0 @@
/**
* 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/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$positive: #047857;
$positive-soft: #ecfdf5;
$accent: #b8860b;
.trc-dialog {
min-width: 420px;
max-width: 540px;
}
.trc-header {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 4px;
&__icon {
flex: none;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 15px;
}
&__text {
display: flex;
flex-direction: column;
min-width: 0;
}
&__title {
font-size: 15px;
font-weight: 700;
color: $navy;
line-height: 1.3;
}
&__sub {
font-size: 12px;
color: $muted;
font-weight: 500;
}
}
.trc-content {
padding-top: 4px;
}
.trc-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
padding: 8px 10px;
background: $soft;
color: $muted;
font-weight: 600;
text-align: left;
border-bottom: 1px solid $border;
}
td {
padding: 9px 10px;
color: $slate;
border-bottom: 1px solid $border;
font-variant-numeric: tabular-nums;
}
&__rate-col {
text-align: right;
}
&__row--applied {
background: $positive-soft;
td {
color: $positive;
font-weight: 700;
}
}
&__sc-ico {
color: $accent;
margin-right: 4px;
}
&__sc-col {
color: $accent;
font-weight: 600;
}
}
.trc-applied-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 10px;
background: $positive;
color: #fff;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
vertical-align: middle;
}
.trc-note {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 10px 2px 0;
color: $muted;
font-size: 11px;
font-style: italic;
line-height: 1.4;
}
.trc-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 8px;
color: $muted;
text-align: center;
&__icon {
font-size: 22px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 13px;
}
}
.trc-actions {
padding-top: 6px;
}
@@ -1,93 +0,0 @@
/**
* 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, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the
* timeline: a chart can run to several tenure bands, and showing it in place would push every later term
* down the page every time one is opened.
*/
@Component({
selector: 'mifosx-term-rate-chart-dialog',
templateUrl: './term-rate-chart-dialog.component.html',
styleUrls: ['./term-rate-chart-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
FaIconComponent
]
})
export class TermRateChartDialogComponent {
dialogRef = inject<MatDialogRef<TermRateChartDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
get term(): any {
return this.data.term;
}
get slabs(): any[] {
return this.data.slabs ?? [];
}
get termLabel(): string {
return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno;
}
/** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */
get isSeniorCitizen(): boolean {
return !!this.data.isSeniorCitizen;
}
/**
* True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the
* true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an
* older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all.
*/
get hasLiveSeniorRate(): boolean {
return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null);
}
/**
* True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen
* term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}.
* Compared as numbers since one side arrives as a string from the currency-agnostic archive row.
*/
isAppliedSlab(slab: any): boolean {
return (
!this.isSeniorCitizen &&
this.term?.interestrate != null &&
Number(slab.annualInterestRate) === Number(this.term.interestrate)
);
}
/** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */
isAppliedSeniorSlab(slab: any): boolean {
return (
this.isSeniorCitizen &&
slab.seniorCitizenRate != null &&
this.term?.interestrate != null &&
Number(slab.seniorCitizenRate) === Number(this.term.interestrate)
);
}
}
@@ -36,11 +36,7 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
@if (transaction.isRenewalEvent) {
<fa-icon icon="sync-alt" class="txn-tab__renewal-ico"></fa-icon>
} @else {
{{ transaction.id }}
}
{{ transaction.id }}
</td>
</ng-container>
@@ -76,13 +72,7 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{
transaction.isRenewalEvent
? '—'
: isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
{{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
</td>
</ng-container>
@@ -94,13 +84,7 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{
transaction.isRenewalEvent
? '—'
: !isDebit(transaction.transactionType)
? (transaction.amount | formatNumber)
: 'N/A'
}}
{{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }}
</td>
</ng-container>
@@ -112,40 +96,36 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }}
{{ transaction.runningBalance | formatNumber }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let transaction; let idx = index">
@if (transaction.isRenewalEvent) {
<span class="txn-tab__renewal-tag">No ledger impact</span>
} @else {
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
}
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
</td>
</ng-container>
@@ -97,32 +97,4 @@ table {
color: $muted;
font-style: italic;
}
.mat-mdc-cell.renewal-marker {
background: #eff6ff;
color: $blue;
font-style: italic;
cursor: default;
}
.mat-mdc-row:has(.renewal-marker):hover {
background: #eff6ff;
cursor: default;
}
}
.txn-tab__renewal-ico {
color: $blue;
}
.txn-tab__renewal-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: #dbeafe;
color: $blue;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -113,71 +113,13 @@ export class TransactionsTabComponent implements OnInit {
*/
constructor() {
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
this.transactionsData = this.withRenewalMarkers(
data.fixedDepositsAccountData.transactions,
data.fixedDepositsAccountData.renewalHistory
);
this.transactionsData = data.fixedDepositsAccountData.transactions;
this.clientId = this.route.parent.snapshot.params['clientId'];
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
this.status = data.fixedDepositsAccountData.status.value;
});
}
/**
* An in-place renewal moves no money -- the matured balance simply becomes the new term's opening
* principal -- so it posts no withdrawal, deposit, or journal entry (see
* DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal
* would be invisible in this list even though the account's rate and term just changed. These rows
* are synthesized purely for display from the renewal history already loaded for the Renewal History
* tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry
* lookup, and every real transaction's running balance stay exactly as they were.
*/
private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] {
const markers: any[] = (renewalHistory ?? [])
.filter((term: any) => term.renewalcycleno > 0)
.map((term: any) => {
const marker: any = {
isRenewalEvent: true,
id: null,
date: term.activatedondate,
amount: null,
runningBalance: null,
reversed: false,
transfer: null,
renewalCycleNo: term.renewalcycleno,
transactionType: {
value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed',
accrual: false,
withdrawal: false,
feeDeduction: false,
overdraftInterest: false,
withholdTax: false
}
};
return marker;
});
return [
...(transactions ?? []),
...markers
].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date));
}
/** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */
private toEpoch(value: any): number {
if (!value) {
return 0;
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
return new Date(year, (month ?? 1) - 1, day ?? 1).getTime();
}
return new Date(value).getTime();
}
ngOnInit() {
this.hideAccrualsParam = new UntypedFormControl(false);
this.hideReversedParam = new UntypedFormControl(false);
@@ -219,9 +161,6 @@ export class TransactionsTabComponent implements OnInit {
* @param {any} transactionsData Transactions Data
*/
showTransactions(transactionsData: any) {
if (transactionsData.isRenewalEvent) {
return;
}
if (transactionsData.transfer) {
this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], {
relativeTo: this.route
@@ -252,10 +191,7 @@ export class TransactionsTabComponent implements OnInit {
this.dataSource.sort = this.sort;
}
savingsTransactionColor(transaction: any): string {
if (transaction.isRenewalEvent) {
return 'renewal-marker';
}
savingsTransactionColor(transaction: SavingsAccountTransaction): string {
if (transaction.reversed) {
return 'strike';
} else if (transaction.transfer) {
@@ -31,7 +31,6 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de
import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver';
import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver';
import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component';
import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component';
import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component';
const routes: Routes = [
@@ -81,15 +80,6 @@ const routes: Routes = [
routeParamBreadcrumb: false
}
},
{
path: 'renewal-history',
component: RenewalHistoryTabComponent,
data: {
title: 'Fixed Deposit Account Renewal History',
breadcrumb: 'Renewal History',
routeParamBreadcrumb: false
}
},
{
path: 'transactions',
component: TransactionsTabComponent,
@@ -87,17 +87,6 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also
* include Partial Re-Invest, which the closure commands exclude because only Payment implements it.
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
*/
getFixedDepositsAccountPaymentTemplate(accountId: string): Observable<any> {
const httpParams = new HttpParams().set('command', 'payment');
return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams });
}
/**
* @param {string} accountId Fixed Deposits Account Id
* @returns {Observable<any>}
@@ -33,6 +33,9 @@
<span class="flex-60">{{ loanProduct.description }}</span>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.details }"
></ng-container>
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Currency' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
@@ -298,6 +301,10 @@
<span class="flex-60">{{ loanProduct.minimumDaysBetweenDisbursalAndFirstRepayment | formatNumber }}</span>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.terms }"
></ng-container>
<ng-container *ngTemplateOutlet="legacyInterestRateDetail"></ng-container>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Settings' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
@if (loanProduct.amortizationType?.value) {
@@ -741,6 +748,9 @@
</div>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.settings }"
></ng-container>
@if (loanProduct.charges.length) {
<div class="flex-fill layout-row-wrap responsive-column">
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Charges' | translate }}</h3>
@@ -813,6 +823,9 @@
</table>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.charges }"
></ng-container>
@if (isAdvancedPaymentAllocation) {
<div class="flex-100 layout-row-wrap responsive-column">
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Income capitalization' | translate }}</h3>
@@ -1271,5 +1284,73 @@
</div>
</div>
}
<ng-container
*ngTemplateOutlet="legacySections; context: { sections: customFieldSectionsByStep.accounting }"
></ng-container>
</div>
}
<!--
Legacy "Account Type Creation" tables. Rendered once here and pulled into each tab's own summary
above via ngTemplateOutlet, so a section sits with the tab it was entered on rather than in a
separate pile at the end of the preview.
-->
<ng-template #legacySections let-sections="sections">
@for (section of sections; track section.title) {
<div class="flex-100 layout-row-wrap responsive-column custom-field-section">
<h4 class="mat-h4 flex-100">{{ section.title }}</h4>
<table class="mat-elevation-z1 flex-100" mat-table [dataSource]="section.rows">
<ng-container matColumnDef="label">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Field' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.label }}</td>
</ng-container>
<ng-container matColumnDef="value">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Value' | translate }}</th>
<td mat-cell *matCellDef="let row">
@switch (row.kind) {
@case ('yesNo') {
{{ row.yesNo | yesNo }}
}
@case ('date') {
{{ row.date | dateFormat }}
}
@case ('number') {
{{ row.number | formatNumber }}
}
@default {
{{ row.text }}
}
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="customFieldDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: customFieldDisplayedColumns"></tr>
</table>
</div>
}
</ng-template>
<!-- The legacy Interest Rate Detail grid, shown under Terms since that is where it is filled in. -->
<ng-template #legacyInterestRateDetail>
@if (interestRateDetailRows.length > 0) {
<div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-100">{{ 'labels.heading.Interest Rate Detail' | translate }}</h4>
<table class="mat-elevation-z1 flex-100" mat-table [dataSource]="interestRateDetailRows">
@for (column of interestRateDetailColumns; track column.key) {
<ng-container [matColumnDef]="column.key">
<th mat-header-cell *matHeaderCellDef>{{ column.label }}</th>
<td mat-cell *matCellDef="let row">
@if (column.key === 'calculation') {
{{ row.calculation | yesNo }}
} @else {
{{ row[column.key] }}
}
</td>
</ng-container>
}
<tr mat-header-row *matHeaderRowDef="interestRateDetailDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: interestRateDetailDisplayedColumns"></tr>
</table>
</div>
}
</ng-template>
@@ -56,3 +56,13 @@ mat-divider {
.repayment-strategy {
color: $status-approved;
}
// Legacy "Account Type Creation" sections. Each is a two-column field/value table, and the widest
// of them (Interest Related Parameters) is long, so the label column is pinned to keep the value
// column from drifting between one section and the next.
.custom-field-section {
th:first-of-type,
td:first-of-type {
width: 45%;
}
}
@@ -26,6 +26,15 @@ import {
PaymentAllocation
} from '../../loan-product-stepper/loan-product-payment-strategy-step/payment-allocation-model';
import { LoanProducts } from '../../loan-products';
import {
CUSTOM_FIELD_LABELS,
CUSTOM_FIELD_SECTIONS,
CUSTOM_GL_ACCOUNT_FIELDS,
CustomFieldStep,
CustomGlAccountKind,
INTEREST_RATE_DETAIL_COLUMNS,
isCustomSectionApplicable
} from '../../loan-product-custom-fields';
import { CodeName, OptionData, StringEnumOptionData } from '../../../../shared/models/option-data.model';
import { Accounting } from 'app/core/utils/accounting';
import { DecimalPipe } from '@angular/common';
@@ -51,6 +60,38 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* One legacy field, ready to render. `kind` picks which pipe the template applies, so formatting
* (translation, dates, numbers) stays in the template rather than being baked into a string here.
*/
export interface CustomFieldRow {
label: string;
kind: 'text' | 'yesNo' | 'date' | 'number';
text?: string;
yesNo?: boolean;
date?: Date;
number?: number;
}
export interface CustomFieldSectionView {
title: string;
rows: CustomFieldRow[];
}
/** Legacy sections keyed by the tab they belong under, so each renders inside that tab's summary. */
export type CustomFieldSectionsByStep = Record<CustomFieldStep, CustomFieldSectionView[]>;
/** A row of the legacy Interest Rate Detail grid. */
export interface InterestRateDetailRow {
interestType: string;
calculation: boolean;
normalMinRate: number | null;
normalMaxRate: number | null;
specialMinRate: number | null;
specialMaxRate: number | null;
period: string;
}
@Component({
selector: 'mifosx-loan-product-summary',
templateUrl: './loan-product-summary.component.html',
@@ -113,6 +154,12 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
'chargeOffReasonCodeValueId',
'expenseAccountId'
];
customFieldDisplayedColumns: string[] = [
'label',
'value'
];
readonly interestRateDetailColumns = INTEREST_RATE_DETAIL_COLUMNS;
readonly interestRateDetailDisplayedColumns: string[] = INTEREST_RATE_DETAIL_COLUMNS.map((column) => column.key);
accountingRuleData: string[] = [];
isAdvancedPaymentAllocation = false;
@@ -128,6 +175,15 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
capitalizedIncomeClassificationToIncomeAccountMappings: ClassificationToIncomeAccountMapping[] = [];
writeOffReasonsToExpenseMappings: ChargeOffReasonToExpenseAccountMapping[] = [];
/** Legacy "Account Type Creation" sections, keyed by the tab each belongs under. */
customFieldSectionsByStep: CustomFieldSectionsByStep = LoanProductSummaryComponent.emptySectionsByStep();
/** The legacy Interest Rate Detail grid, shown as its own table since its value is a row array. */
interestRateDetailRows: InterestRateDetailRow[] = [];
private static emptySectionsByStep(): CustomFieldSectionsByStep {
return { details: [], settings: [], terms: [], charges: [], accounting: [] };
}
ngOnInit() {
this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
this.setCurrentValues();
@@ -518,6 +574,8 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
this.loanProductsTemplate.advancedPaymentAllocationFutureInstallmentAllocationRules
};
}
this.setCustomFieldValues();
}
optionDataLookUp(itemId: any, optionsData: any[]): OptionData {
@@ -658,4 +716,119 @@ export class LoanProductSummaryComponent implements OnInit, OnChanges {
mapHumanReadableValueStringEnumOptionDataList(incomingParameter: StringEnumOptionData[]): string[] {
return incomingParameter.map((v) => v.value);
}
/**
* Builds the legacy "Account Type Creation" tables, which only exist on the create/edit forms
* (see CUSTOM_FIELD_SECTIONS) — reviewed here because `LoanProducts.buildPayload()` drops them
* from the submit payload before it reaches Fineract.
*
* Sections are keyed by their originating tab so each renders inside that tab's own summary. A
* "Cheque Book Settings" table is only reviewable next to the rest of Settings; collected into
* one block at the bottom, it reads as an unrelated pile.
*
* Only genuinely unset values are hidden. A field the officer deliberately set to No or
* false is part of the configuration being reviewed, so it is shown like any other answer.
*/
private setCustomFieldValues(): void {
this.customFieldSectionsByStep = LoanProductSummaryComponent.emptySectionsByStep();
this.interestRateDetailRows = [];
if (!this.loanProduct) {
return;
}
this.interestRateDetailRows = this.buildInterestRateDetailRows((this.loanProduct as any)['interestRateDetail']);
const productSubGroup: string = (this.loanProduct as any)['productTypeSubGroup'];
for (const section of CUSTOM_FIELD_SECTIONS) {
// A section the Settings step hides for this sub-group is not part of the product's
// configuration, so listing its defaulted controls here would only invite the reader to
// review settings that do not apply -- a Gold Loan has no cheque book.
if (!isCustomSectionApplicable(section.title, productSubGroup)) {
continue;
}
const rows: CustomFieldRow[] = [];
for (const field of section.fields) {
// Rendered as its own table under the Terms summary.
if (field === 'interestRateDetail') {
continue;
}
const row = this.buildCustomFieldRow(field, (this.loanProduct as any)[field]);
if (row) {
rows.push(row);
}
}
if (rows.length > 0) {
this.customFieldSectionsByStep[section.step].push({ title: section.title, rows });
}
}
}
private buildCustomFieldRow(field: string, value: any): CustomFieldRow | null {
if (value === null || value === undefined || value === '') {
return null;
}
const label = CUSTOM_FIELD_LABELS[field] ?? field;
const glAccountKind = CUSTOM_GL_ACCOUNT_FIELDS[field];
if (glAccountKind) {
const account = this.glAccountLookUp(value, this.glAccountOptionsFor(glAccountKind));
return {
label,
kind: 'text',
// An id with no matching account means the option lists have not loaded yet, so show the
// raw id rather than a blank cell.
text: account ? '(' + account.glCode + ') ' + account.name : String(value)
};
}
if (typeof value === 'boolean') {
return { label, kind: 'yesNo', yesNo: value };
}
if (value === 'YES' || value === 'NO') {
return { label, kind: 'yesNo', yesNo: value === 'YES' };
}
// Only real Date instances go through the date pipe -- a value already formatted by a step's
// getter is left as text so it is not reformatted a second time.
if (value instanceof Date) {
return { label, kind: 'date', date: value };
}
// Numeric strings such as account or MICR codes stay text, so leading zeros survive.
if (typeof value === 'number') {
return { label, kind: 'number', number: value };
}
return { label, kind: 'text', text: String(value) };
}
/** Drops the untouched rows of the legacy Interest Rate Detail grid. */
private buildInterestRateDetailRows(value: any): InterestRateDetailRow[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((row: any) =>
this.interestRateDetailColumns.some(
(column) =>
column.key !== 'interestType' &&
row?.[column.key] !== null &&
row?.[column.key] !== undefined &&
row?.[column.key] !== '' &&
row?.[column.key] !== false
)
);
}
private glAccountOptionsFor(kind: CustomGlAccountKind): GLAccount[] {
const options = this.loanProductsTemplate?.accountingMappingOptions;
switch (kind) {
case 'asset':
return options?.assetAccountOptions || [];
case 'income':
return options?.incomeAccountOptions || [];
case 'expense':
return options?.expenseAccountOptions || [];
case 'liability':
return options?.liabilityAccountOptions || [];
}
}
}
@@ -26,6 +26,7 @@
<div class="lp-tab-content">
<mifosx-loan-product-details-step
[loanProductsTemplate]="loanProductsTemplate"
(productSubGroupChange)="setProductSubGroup($event)"
></mifosx-loan-product-details-step>
</div>
</mat-tab>
@@ -51,6 +52,7 @@
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
[productSubGroup]="selectedProductSubGroup"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
@@ -88,6 +88,7 @@ export class CreateLoanProductComponent implements OnInit {
selectedTabIndex = 0;
isAdvancedPaymentStrategy = false;
selectedProductSubGroup = '';
paymentAllocation: PaymentAllocation[] = [];
creditAllocation: PaymentAllocation[] = [];
supportedInterestRefundTypes: StringEnumOptionData[] = [];
@@ -144,6 +145,10 @@ export class CreateLoanProductComponent implements OnInit {
return this.loanProductTermsStep.loanProductTermsForm;
}
setProductSubGroup(value: string) {
this.selectedProductSubGroup = value;
}
advancePaymentStrategy(value: string) {
this.isAdvancedPaymentStrategy = LoanProducts.isAdvancedPaymentAllocationStrategy(value);
if (this.isAdvancedPaymentStrategy) {
@@ -244,6 +249,53 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductFormValid() {
// TEMP DEBUG — remove once the invalid-step diagnosis is done.
const forms: [string, any][] = [
[
'details',
this.loanProductDetailsForm
],
[
'currency',
this.loanProductCurrencyForm
],
[
'terms',
this.loanProductTermsForm
],
[
'settings',
this.loanProductSettingsForm
],
[
'accounting',
this.loanProductAccountingForm
]
];
if (this.isAdvancedPaymentStrategy) {
forms.push([
'incomeCapitalization',
this.loanIncomeCapitalizationForm
]);
}
const invalid = forms.filter(
([
,
form
]) => !form?.valid
);
if (invalid.length) {
invalid.forEach(
([
name,
form
]) => {
const invalidControls = Object.keys(form.controls || {}).filter((key) => form.get(key)?.invalid);
console.warn(`[loanProductFormValid] "${name}" form invalid. Invalid controls:`, invalidControls);
}
);
}
if (this.isAdvancedPaymentStrategy) {
return (
this.loanProductDetailsForm.valid &&
@@ -39,6 +39,7 @@
<mifosx-loan-product-details-step
[loanProductsTemplate]="loanProductAndTemplate"
(productSubGroupChange)="setProductSubGroup($event)"
></mifosx-loan-product-details-step>
</mat-step>
@@ -56,6 +57,7 @@
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductAndTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
[productSubGroup]="selectedProductSubGroup"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
>
</mifosx-loan-product-settings-step>
@@ -95,6 +95,7 @@ export class EditLoanProductComponent implements OnInit {
itemsByDefault: GlobalConfiguration[] = [];
isAdvancedPaymentStrategy = false;
selectedProductSubGroup = '';
wasPaymentAllocationChanged = false;
paymentAllocation: PaymentAllocation[] = [];
creditAllocation: CreditAllocation[] = [];
@@ -116,7 +117,9 @@ export class EditLoanProductComponent implements OnInit {
const loanProducts = this.loanProducts;
this.route.data.subscribe((data: { loanProductAndTemplate: any; configurations: any }) => {
this.loanProductAndTemplate = data.loanProductAndTemplate;
// Lift the saved legacy "Account Type Creation" fields to the top level before any step reads
// them, since the backend returns them nested under `cbsExtensions`.
this.loanProductAndTemplate = loanProducts.flattenCbsExtensions(data.loanProductAndTemplate);
const assetAccountData = this.loanProductAndTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductAndTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductAndTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
@@ -218,6 +221,10 @@ export class EditLoanProductComponent implements OnInit {
this.isAdvancedPaymentStrategy = LoanProducts.isAdvancedPaymentAllocationStrategy(value);
}
setProductSubGroup(value: string): void {
this.selectedProductSubGroup = value;
}
buildAdvancedPaymentAllocation(): void {
this.advancedPaymentAllocations = this.advancedPaymentStrategy.buildAdvancedPaymentAllocationList(
this.loanProductAndTemplate
@@ -0,0 +1,820 @@
/**
* 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/.
*/
/**
* Metadata for the legacy "Account Type Creation" fields grafted onto the loan product form.
*
* These fields have no home in Fineract's loan product schema, so `LoanProducts.buildPayload()`
* strips them before submitting. They are still worth showing in full on the preview step, which is
* what this file exists for: the grouping and labels here mirror the create/edit form, so an
* officer reviews the same wording they typed against.
*
* Each section carries the `step` it was entered on, so the preview can show a section directly
* under that tab's own summary instead of collecting every legacy field into one block at the
* bottom -- reviewing "Cheque Book Settings" is only meaningful next to the rest of Settings.
*
* `CUSTOM_UI_ONLY_FIELDS` is derived from `CUSTOM_FIELD_SECTIONS` rather than kept alongside it, so
* the strip list and the preview can never disagree about which fields are custom.
*/
import { UntypedFormGroup } from '@angular/forms';
/** Which set of GL accounts a custom mapping field draws from, for resolving an id to an account. */
export type CustomGlAccountKind = 'asset' | 'income' | 'expense' | 'liability';
/** The create/edit tab a section's fields are entered on. */
export type CustomFieldStep = 'details' | 'settings' | 'terms' | 'charges' | 'accounting';
export interface CustomFieldSection {
/** Heading shown on the preview step. */
title: string;
/** Tab this section belongs under. */
step: CustomFieldStep;
fields: string[];
}
export const CUSTOM_FIELD_SECTIONS: CustomFieldSection[] = [
{
title: 'Classification & Legacy Header',
step: 'details',
fields: [
'productTypeGroup',
'productTypeSubGroup',
'debitIntCalculation',
'separatePenalInterest',
'creditIntCalculation',
'subAccountTypeBalance',
'finalIntRounding',
'roundingUpTo',
'accountTypeCode',
'micrTransactionCode',
'reservedEcsSerialNumber',
'reverseEcsNumber',
'lockChequeInfoEnabled',
'lockPageTwoInfoEnabled',
'computerizationDate',
'chequePrintDescription',
'posAccountTypeDescription',
'pfmsAccountType',
'renewalDateAddMonths',
'documentRenewalDateAddMonths',
'ppsLimit',
'isWriteOffAccountType',
'writeOffAccountTypeCode',
'renewalGraceDays'
]
},
{
title: 'Cheque Book Settings',
step: 'settings',
fields: [
'chequeBookIssueEnabled',
'chequeValidationRequired',
'isFirstChequeBookFree',
'chequePrintingAccountType',
'allowWithdrawalSlipUsage',
'maintainChequeBookStockRegistry',
'freeChequeBooksPerYear',
'allowCreditByChequeNumber',
'creditChequeValidationRequired',
'graceDaysForStockStatement'
]
},
{
title: 'Additional Product Settings',
step: 'settings',
fields: [
'accountTypeActiveForTransactions',
'subTypeActivityEnabled',
'accountOpeningCharges',
'maximumGoldLoanLimit',
'lastAccountNumberOpened',
'customerTerminationAllowed',
'chargesForCashCreditAboveLimit',
'deathInterestGLAccount',
'startingLedgerNumber',
'endingLedgerNumber',
'currentLedgerNumber',
'remittanceAccountNumber',
'isNonResidentIndianAccount',
'debitClearingGLAccount',
'missedCallAlertServiceActive',
'standingInstructionChargeGLAccount',
'standingInstructionDebitChargeSkipOption',
'openingBalanceMultiplierForRemittanceCharges',
'goldLoanPacketNumberWithYearEnabled',
'accountMoveToInoperativeEnabled',
'generateGlDiffEntryEnabled',
'subTypeWiseGlBalanceGenerationEnabled',
'validateBeforeDeletingDebitEntryEnabled',
'rcFileConceptEnabled',
'accountCloseZeroBalanceInOpenDayProcess',
'accountCloseZeroBalanceAtSubledgerTranEntry',
'limitEnhancementAgainstClearingScreenDisplayEnabled',
'advanceInterestParameterInLedgerEnabled',
'vehicleDetailInMasterEnabled',
'deletedChequeIssueEnabled',
'dpReduceInDocumentRenewalDateEnabled',
'dpReduceInRenewalDateEnabled',
'autoCloseAtDayEndEnabled',
'loanRateFromMasterEnabled',
'chequeShortName',
'statementPrintEnabled',
'neslCode'
]
},
{
title: 'Interest Related Parameters',
step: 'settings',
fields: [
'drawingPowerAndLimitGenerationProcedure',
'advancesInterestCalculationMethod',
'againstInterestCalculationEnabled',
'dayWiseAgainstClearingInterestRounding',
'interestCalculationOneFourthMethod',
'againstClearingInterestProcedureType',
'considerDebitClosingAmountForAgainstClearing',
'interestRoundingMethod',
'penaltyInterestCalculationEnabled',
'penaltyInterestCalculationMethod',
'penaltyInterestProcedureNumber',
'overdrawnInterestCalculationMethod',
'calculateOverdrawnInterestAfterRenewal',
'interestOnCreditBalance',
'penaltyInterestDateDifferentThanRegularInterest',
'fixedDepositOverdraftCertificateSortOrder',
'useDocumentSubmissionDateForInterest',
'loanManagementSystemRenewalPenaltyActive',
'minimumNormalInterestAmount',
'minimumInterestCalculationMethod',
'minimumAgainstClearingInterestAmount',
'limitIncreaseUptoInterestAmountInInterestCalculation',
'minimumPenaltyInterestAmount',
'daysForLimitIncreaseUptoInterestAmount',
'additionalInterestRateForFixedDepositOverdraft',
'considerDdPayorderInAgainstClearingInterestCalculation',
'graceDaysAfterRenewDate',
'applyUndebitedInterestConceptEnabled',
'interestRegisterPrintFormatType',
'normalGraceDaysAfterOverdue',
'allowAgainstClearingEntry',
'allowNoBalanceEntry',
'fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest',
'allowNoBalanceEntryWithPassword',
'undebitedSimpleInterestAccountWise',
'dailyInterestCalculationEnabled',
'dailyInterestCalculationStartDate',
'nonPerformingAssetPenalInterestRateUpdate',
'nonPerformingAssetPenalInterestRate',
'fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess',
'addPenalInterestRate',
'atyp14SimpleInterestEnabled',
'fixedDepositOverdraftMarginType',
'checkMasterDetailEditedPassingPendingEnabled',
'creditDebitInterestCalculationSeparateEnabled',
'onTimeInterestProvisionEnabled',
'checkAgainstClearingEnabled',
'gstOnPenaltyEnabled',
'penaltyInterestGraceDays',
'overdrawnInterestRate',
'calculateDailyLimitEnhancementTodEnabled',
'fixedDepositOverdraftMultipleInterestRateEnabled',
'graceDaysAfterOverdueForMoratorium'
]
},
{
title: 'Credit Bureau Settings (CIBIL)',
step: 'settings',
fields: [
'cibilAccountType',
'cibilCommercialAccountType',
'highmarkAccountType',
'highmarkCommercialAccountType',
'experianAccountType',
'experianCommercialAccountType',
'equifaxAccountType',
'equifaxCommercialAccountType'
]
},
{
title: 'RBI Compliance & Product Category',
step: 'settings',
fields: [
'loanProductCategory',
'penalChargeMethod',
'isFloatingRate',
'foreclosureChargesApplicable',
'keyFactStatementEnabled',
'annualPercentageRateCalculationEnabled'
]
},
{
title: 'Channel & Transaction Limits',
step: 'settings',
fields: [
'isMobileAppEnabled',
'isAtmEnabled',
'atmYearlyChargeEnabled',
'atmMinimumBalance',
'emailActiveEnabled',
'isImpsEnabled',
'isUpiEnabled',
'isPosEnabled',
'isBbpsEnabled',
'interBankLimitPerDay',
'interBankLimitPerTransaction',
'neftRtgsLimitPerDay',
'neftRtgsLimitPerTransaction',
'neftCutoffLimit',
'rtgsCutoffLimit',
'neftRtgsMinimumBalance',
'isChargeRequiredNeftRtgsCbs',
'isChargeRequiredNeftRtgsMobile',
'impsLimitPerDay',
'impsLimitPerTransaction',
'impsLimitPerMonth',
'impsMinLimit',
'impsMonthlyTransactionCount'
]
},
{
title: 'POS Facility & ATM CSV Settings',
step: 'settings',
fields: [
'maxPerTransactionLimitAtm',
'maxDayTransactionLimitAtm',
'maxPerTransactionLimitPos',
'maxDayTransactionLimitPos',
'maxPerTransactionLimitEcom',
'maxDayTransactionLimitEcom',
'maxPerTransactionLimitUpi',
'maxDayTransactionLimitUpi',
'atmCsvProductCode',
'atmCsvTypeCode'
]
},
{
title: 'Auto Interest Post Parameters',
step: 'settings',
fields: [
'autoInterestPostType',
'autoInterestPostWhen',
'autoInterestPostFixDay',
'autoLastInterestDate'
]
},
{
title: 'Foreclosure / Early Account Closure',
step: 'settings',
fields: [
'calculateEarlyAccountCloseCharge',
'earlyAccountCloseCharge',
'earlyAccountClosePeriodInMonths'
]
},
{
title: 'Legacy Terms',
step: 'terms',
fields: [
'debitBalanceAllow',
'allowDebitTransAfterDueDate',
'branchLevelAccountType',
'interestRateDetail'
]
},
{
title: 'Legacy Cheque, Standing Instruction & Minimum Balance Charges',
step: 'charges',
fields: [
'chequeBookChargeEnabled',
'chequeReturnChargeEnabled',
'icChargeDebitEnabled',
'cashBundleChargeEnabled',
'cashBundleChargeWithPanLimit',
'perChequeEntryCharge',
'perChequeEntryServiceCharge',
'perInwardChequeEntryCharge',
'perOutwardChequeEntryCharge',
'creditorStandingInstructionFailCharge',
'debtorStandingInstructionFailCharge',
'minimumBalanceCheckingType',
'autoMinimumBalanceCharge',
'calculateDailyMinimumBalanceCharges',
'minimumBalanceChargeEnabled',
'minimumBalanceChargeAmount',
'minimumBalanceChargeForChequeIssue',
'minimumBalanceChargeMethod',
'monthsAfterMinBalanceChargeCalculate',
'forChequeIssue',
'forChequeIssueAvg',
'forWithdrawalSlip',
'forWithdrawalSlipAvg',
'calculateDailyAgainstClearingCharge'
]
},
{
title: 'Custom GL Account Mappings',
step: 'accounting',
fields: [
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId',
'glAccountId',
'icChargeGlAccountId',
'inoperativeGlAccountId',
'creditBalanceLiabilityGlAccountId',
'creditBalanceAssetGlAccountId',
'overdueAccountsGlAccountId',
'rebateGlAccountId'
]
}
];
/** Form labels, copied from the create/edit step templates so preview wording matches. */
export const CUSTOM_FIELD_LABELS: Record<string, string> = {
productTypeGroup: 'Account Type Group',
productTypeSubGroup: 'Product Type Sub Group',
debitIntCalculation: 'Debit Int. Calculation',
separatePenalInterest: 'Separate Penal Int.',
creditIntCalculation: 'Credit Int. Calculation',
subAccountTypeBalance: 'Sub A/c Type Balance',
finalIntRounding: 'Final Int. Rounding',
roundingUpTo: 'Rounding UpTo',
accountTypeCode: 'A/c Type Code',
micrTransactionCode: 'MICR Trn. Code',
reservedEcsSerialNumber: 'Reserved Code Srno For ECS',
reverseEcsNumber: 'Reverse ECS No.',
lockChequeInfoEnabled: 'Lock Cheque Info',
lockPageTwoInfoEnabled: 'Lock Page 2 Info',
computerizationDate: 'Computerization Date',
chequePrintDescription: 'Chq. Print Description',
posAccountTypeDescription: 'POS A/c Type Description',
pfmsAccountType: 'PFMS Account Type',
renewalDateAddMonths: 'Renewal Dt. Add Months',
documentRenewalDateAddMonths: 'Doc. Renewal Dt. Add Months',
ppsLimit: 'PPS Limit',
isWriteOffAccountType: 'Is this Actyp Write Off?',
writeOffAccountTypeCode: 'Write Off Actyp',
renewalGraceDays: 'Renewal Grace Days',
chequeBookIssueEnabled: 'Cheque Book Issue Enabled',
chequeValidationRequired: 'Cheque Validation Required',
isFirstChequeBookFree: 'Is First Cheque Book Free',
chequePrintingAccountType: 'Cheque Printing Account Type',
allowWithdrawalSlipUsage: 'Allow Withdrawal Slip Usage',
maintainChequeBookStockRegistry: 'Maintain Cheque Book Stock Registry',
freeChequeBooksPerYear: 'Free Cheque Books Per Year',
allowCreditByChequeNumber: 'Allow Credit By Cheque Number',
creditChequeValidationRequired: 'Credit Cheque Validation Required',
graceDaysForStockStatement: 'Grace Days For Stock Statement',
accountTypeActiveForTransactions: 'Account Type Active For Transactions',
subTypeActivityEnabled: 'Sub Type Activity Enabled',
accountOpeningCharges: 'Account Opening Charges',
maximumGoldLoanLimit: 'Maximum Gold Loan Limit',
lastAccountNumberOpened: 'Last Account Number Opened',
customerTerminationAllowed: 'Customer Termination Allowed',
chargesForCashCreditAboveLimit: 'Charges For Cash Credit Above Limit',
deathInterestGLAccount: 'Death Interest GL Account',
startingLedgerNumber: 'Starting Ledger Number',
endingLedgerNumber: 'Ending Ledger Number',
currentLedgerNumber: 'Current Ledger Number',
remittanceAccountNumber: 'Remittance Account Number',
isNonResidentIndianAccount: 'Is Non Resident Indian Account',
debitClearingGLAccount: 'Debit Clearing GL Account',
missedCallAlertServiceActive: 'Missed Call Alert Service Active',
standingInstructionChargeGLAccount: 'Standing Instruction Charge GL Account',
standingInstructionDebitChargeSkipOption: 'Standing Instruction Debit Charge Skip Option',
openingBalanceMultiplierForRemittanceCharges: 'Opening Balance Multiplier For Remittance Charges',
goldLoanPacketNumberWithYearEnabled: 'Gold Loan [Packet No.] With Year?',
accountMoveToInoperativeEnabled: 'A/c Move To InOperative A/c',
generateGlDiffEntryEnabled: 'Generate GL Diff Entry',
subTypeWiseGlBalanceGenerationEnabled: 'Sub Type Wise GL Balance Gen',
validateBeforeDeletingDebitEntryEnabled: 'Want To Validation While Deleting Debit Entry',
rcFileConceptEnabled: 'Want To RC File Concept',
accountCloseZeroBalanceInOpenDayProcess: 'A/c Close With Zero Bal. In Open Day Process',
accountCloseZeroBalanceAtSubledgerTranEntry: 'A/c Close When Bal. Zero At Subled Tran Entry',
limitEnhancementAgainstClearingScreenDisplayEnabled: 'Lmt.Enh/Ag.Clg Screen Display',
advanceInterestParameterInLedgerEnabled: 'Advance Int. Param In Ledger',
vehicleDetailInMasterEnabled: 'Vehicle Detail In Master',
deletedChequeIssueEnabled: 'Deleted Chq. Issue',
dpReduceInDocumentRenewalDateEnabled: 'DP Reduce In Doc. Renewal Date',
dpReduceInRenewalDateEnabled: 'DP Reduce In Renewal Date',
autoCloseAtDayEndEnabled: 'Auto Close Day-End',
loanRateFromMasterEnabled: 'Loan Rate From Master',
chequeShortName: 'Cheque Short Name',
statementPrintEnabled: 'Statement Print',
neslCode: 'NESL Code',
drawingPowerAndLimitGenerationProcedure: 'Drawing Power And Limit Generation Procedure',
advancesInterestCalculationMethod: 'Advances Interest Calculation Method',
againstInterestCalculationEnabled: 'Against Interest Calculation Enabled',
dayWiseAgainstClearingInterestRounding: 'Day Wise Against Clearing Interest Rounding',
interestCalculationOneFourthMethod: 'Interest Calculation One Fourth Method',
againstClearingInterestProcedureType: 'Against Clearing Interest Procedure Type',
considerDebitClosingAmountForAgainstClearing: 'Consider Debit Closing Amount For Against Clearing',
interestRoundingMethod: 'Interest Rounding Method',
penaltyInterestCalculationEnabled: 'Penalty Interest Calculation Enabled',
penaltyInterestCalculationMethod: 'Penalty Interest Calculation Method',
penaltyInterestProcedureNumber: 'Penalty Interest Procedure Number',
overdrawnInterestCalculationMethod: 'Overdrawn Interest Calculation Method',
calculateOverdrawnInterestAfterRenewal: 'Calculate Overdrawn Interest After Renewal',
interestOnCreditBalance: 'Interest On Credit Balance',
penaltyInterestDateDifferentThanRegularInterest: 'Penalty Interest Date Different Than Regular Interest',
fixedDepositOverdraftCertificateSortOrder: 'Fixed Deposit Overdraft Certificate Sort Order',
useDocumentSubmissionDateForInterest: 'Use Document Submission Date For Interest',
loanManagementSystemRenewalPenaltyActive: 'Loan Management System Renewal Penalty Active',
minimumNormalInterestAmount: 'Minimum Normal Interest Amount',
minimumInterestCalculationMethod: 'Minimum Interest Calculation Method',
minimumAgainstClearingInterestAmount: 'Minimum Against Clearing Interest Amount',
limitIncreaseUptoInterestAmountInInterestCalculation: 'Limit Increase Upto Interest Amount In Interest Calculation',
minimumPenaltyInterestAmount: 'Minimum Penalty Interest Amount',
daysForLimitIncreaseUptoInterestAmount: 'Days For Limit Increase Upto Interest Amount',
additionalInterestRateForFixedDepositOverdraft: 'Additional Interest Rate For Fixed Deposit Overdraft',
considerDdPayorderInAgainstClearingInterestCalculation:
'Consider DD/Payorder In Against Clearing Interest Calculation',
graceDaysAfterRenewDate: 'Grace Days After Renewal Date',
applyUndebitedInterestConceptEnabled: 'Apply Undebited Interest Concept Enabled',
interestRegisterPrintFormatType: 'Interest Register Print Format Type',
normalGraceDaysAfterOverdue: 'Normal Grace Days After Overdue',
allowAgainstClearingEntry: 'Allow Against Clearing Entry',
allowNoBalanceEntry: 'Allow No Balance Entry',
fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest:
'Fixed Deposit Overdraft Penalty Interest Combined With Normal Interest',
allowNoBalanceEntryWithPassword: 'Allow No Balance Entry With Password',
undebitedSimpleInterestAccountWise: 'Undebited Simple Interest Account Wise',
dailyInterestCalculationEnabled: 'Daily Interest Calculation Enabled',
dailyInterestCalculationStartDate: 'Daily Interest Calculation Start Date',
nonPerformingAssetPenalInterestRateUpdate: 'Non Performing Asset Penal Interest Rate Update',
nonPerformingAssetPenalInterestRate: 'Non Performing Asset Penal Interest Rate',
fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess:
'Fixed Deposit Overdraft Penal Interest Rate Update In Branch Process',
addPenalInterestRate: 'Add Penal Interest Rate',
atyp14SimpleInterestEnabled: 'Atyp14 Simple Int.',
fixedDepositOverdraftMarginType: 'FDOD Margin Type',
checkMasterDetailEditedPassingPendingEnabled: 'Check Master Detail Edited (Passing Pend.)',
creditDebitInterestCalculationSeparateEnabled: 'Credit/Debit Int. Calc. Separate',
onTimeInterestProvisionEnabled: 'On Time Interest Provision',
checkAgainstClearingEnabled: 'Check Against Clearing',
gstOnPenaltyEnabled: 'GST On Penalty',
penaltyInterestGraceDays: 'Penalty Int. Grace Days',
overdrawnInterestRate: 'Over Drawn Int. Rate',
calculateDailyLimitEnhancementTodEnabled: 'Calculate Daily Limit Enhancement (TOD)',
fixedDepositOverdraftMultipleInterestRateEnabled: 'FDOD Multiple Int. Rate',
graceDaysAfterOverdueForMoratorium: 'Grace Days After Overdue For Moratorium A/c',
cibilAccountType: 'CIBIL Account Type',
cibilCommercialAccountType: 'CIBIL Commercial Account Type',
highmarkAccountType: 'Highmark Account Type',
highmarkCommercialAccountType: 'Highmark Commercial Account Type',
experianAccountType: 'Experian Account Type',
experianCommercialAccountType: 'Experian Commercial Account Type',
equifaxAccountType: 'Equifax Account Type',
equifaxCommercialAccountType: 'Equifax Commercial Account Type',
loanProductCategory: 'Loan Product Category (RBI Reporting)',
penalChargeMethod: 'Penal Charge Method',
isFloatingRate: 'Penal Charge Method',
foreclosureChargesApplicable: 'Foreclosure Charges Applicable',
keyFactStatementEnabled: 'Foreclosure Charges Applicable',
annualPercentageRateCalculationEnabled: 'Foreclosure Charges Applicable',
isMobileAppEnabled: 'Is Mobile App?',
isAtmEnabled: 'Is ATM?',
atmYearlyChargeEnabled: 'ATM Yearly Charge?',
atmMinimumBalance: 'ATM Min. Balance',
emailActiveEnabled: 'Email Active?',
isImpsEnabled: 'Is IMPS?',
isUpiEnabled: 'Is UPI?',
isPosEnabled: 'Is POS?',
isBbpsEnabled: 'Is BBPS?',
interBankLimitPerDay: 'Inter Bank Limit (Per Day)',
interBankLimitPerTransaction: 'Inter Bank Limit (Per Transaction)',
neftRtgsLimitPerDay: 'NEFT/RTGS Limit (Per Day)',
neftRtgsLimitPerTransaction: 'NEFT/RTGS Limit (Per Transaction)',
neftCutoffLimit: 'NEFT Cutoff Limit',
rtgsCutoffLimit: 'RTGS Cutoff Limit',
neftRtgsMinimumBalance: 'NEFT/RTGS Min. Balance',
isChargeRequiredNeftRtgsCbs: 'Is Charge Req. NEFT/RTGS (CBS)?',
isChargeRequiredNeftRtgsMobile: 'Is Charge Req. NEFT/RTGS (Mobile)?',
impsLimitPerDay: 'IMPS Limit (Per Day)',
impsLimitPerTransaction: 'IMPS Limit (Per Transaction)',
impsLimitPerMonth: 'IMPS Limit (Per Month)',
impsMinLimit: 'IMPS Min. Limit',
impsMonthlyTransactionCount: 'IMPS Monthly Transaction Count',
maxPerTransactionLimitAtm: 'Max Per Transaction Limit (ATM)',
maxDayTransactionLimitAtm: 'Max Day Transaction Limit (ATM)',
maxPerTransactionLimitPos: 'Max Per Transaction Limit (POS)',
maxDayTransactionLimitPos: 'Max Day Transaction Limit (POS)',
maxPerTransactionLimitEcom: 'Max Per Transaction Limit (ECOM)',
maxDayTransactionLimitEcom: 'Max Day Transaction Limit (ECOM)',
maxPerTransactionLimitUpi: 'Max Per Transaction Limit (UPI)',
maxDayTransactionLimitUpi: 'Max Day Transaction Limit (UPI)',
atmCsvProductCode: 'ATM CSV Product Code',
atmCsvTypeCode: 'ATM CSV Type Code',
autoInterestPostType: 'Auto Int. Post Type',
autoInterestPostWhen: 'Auto Int. Post When',
autoInterestPostFixDay: 'Fix Day',
autoLastInterestDate: 'Auto Last Int. Date',
calculateEarlyAccountCloseCharge: 'Calculate Early A/c Close Charge?',
earlyAccountCloseCharge: 'Early A/c Close Charge',
earlyAccountClosePeriodInMonths: 'Early A/c Close Period (In Months)',
debitBalanceAllow: 'Debit Balance Allow',
allowDebitTransAfterDueDate: 'Allow Debit Trans After Due Date',
branchLevelAccountType: 'Branch Level A/c Type',
interestRateDetail: 'Interest Rate Detail',
chequeBookChargeEnabled: 'Chq. Book Charge',
chequeReturnChargeEnabled: 'Chq. Return Charge',
icChargeDebitEnabled: 'I.C. Charge Debit',
cashBundleChargeEnabled: 'Cash Bundle Charge',
cashBundleChargeWithPanLimit: 'Cash Bundle Charge With PAN Limit',
perChequeEntryCharge: 'Per Cheque Entry Charge',
perChequeEntryServiceCharge: 'Per Cheque Entry Service Charge',
perInwardChequeEntryCharge: 'Per Inward Cheque Entry Charge',
perOutwardChequeEntryCharge: 'Per Outward Cheque Entry Charge',
creditorStandingInstructionFailCharge: 'Creditor Standing Instr. Fail Charge',
debtorStandingInstructionFailCharge: 'Debtor Standing Instr. Fail Charge',
minimumBalanceCheckingType: 'Minimum Balance Checking',
autoMinimumBalanceCharge: 'Auto Minimum Balance Charge',
calculateDailyMinimumBalanceCharges: 'Calculate Daily Min. Balance Charges',
minimumBalanceChargeEnabled: 'Minimum Balance Charge',
minimumBalanceChargeAmount: 'Min. Balance Charge (Rs.)',
minimumBalanceChargeForChequeIssue: 'Min. Balance Charge For Cheque Issue (Rs.)',
minimumBalanceChargeMethod: 'Min. Balance Charge Method',
monthsAfterMinBalanceChargeCalculate: 'Months After Min. Balance Charge Calculate',
forChequeIssue: 'For Cheque Issue',
forChequeIssueAvg: 'For Cheque Issue (Avg.)',
forWithdrawalSlip: 'For Withdrawal Slip',
forWithdrawalSlipAvg: 'For Withdrawal Slip (Avg.)',
calculateDailyAgainstClearingCharge: 'Calculate Daily Against Clearing Charge',
intReceivablesGlAccountId: 'Int. Receivables GL A/c',
intPayableGlAccountId: 'Int. Payable GL A/c',
deafIntPayableGlAccountId: 'Deaf Int. Payble GL A/c',
intOverdueReserveAccountId: 'Int. Overdue Reserve A/c (AS)',
undebitedInterestAccountId: 'UnDebited Interest A/c (LI)',
penalIntCrGlAccountId: 'Penal Int. Cr. GL A/c',
rdMaturedGlAccountId: 'RD Matured GL A/c',
glAccountId: 'GL A/c No.',
icChargeGlAccountId: 'I.C. Charge GL A/c No.',
inoperativeGlAccountId: 'InOperative GL A/c',
creditBalanceLiabilityGlAccountId: 'Cr.Balance Liability GL A/c',
creditBalanceAssetGlAccountId: 'Cr.Balance Asset GL A/c',
overdueAccountsGlAccountId: 'Overdue Accounts GL A/c No.',
rebateGlAccountId: 'Rebate GL A/c No.'
};
/**
* Custom GL mapping fields hold an account id, so the preview resolves each to its account rather
* than printing a bare number. The kind picks which option list from the product template to
* search -- it matches the `options` the Accounting step's own field config uses.
*/
export const CUSTOM_GL_ACCOUNT_FIELDS: Record<string, CustomGlAccountKind> = {
intReceivablesGlAccountId: 'asset',
intPayableGlAccountId: 'liability',
deafIntPayableGlAccountId: 'asset',
intOverdueReserveAccountId: 'asset',
undebitedInterestAccountId: 'liability',
penalIntCrGlAccountId: 'income',
rdMaturedGlAccountId: 'asset',
glAccountId: 'asset',
icChargeGlAccountId: 'income',
inoperativeGlAccountId: 'asset',
creditBalanceLiabilityGlAccountId: 'liability',
creditBalanceAssetGlAccountId: 'asset',
overdueAccountsGlAccountId: 'asset',
rebateGlAccountId: 'expense'
};
/** Columns of the legacy Interest Rate Detail grid, whose value is a row array rather than a scalar. */
export const INTEREST_RATE_DETAIL_COLUMNS: { key: string; label: string }[] = [
{
key: 'interestType',
label: 'Interest Type'
},
{
key: 'calculation',
label: 'Calculation'
},
{
key: 'normalMinRate',
label: 'Normal Min. Rate'
},
{
key: 'normalMaxRate',
label: 'Normal Max. Rate'
},
{
key: 'specialMinRate',
label: 'Special Min. Rate'
},
{
key: 'specialMaxRate',
label: 'Special Max. Rate'
},
{
key: 'period',
label: 'Period'
}
];
/**
* Legacy fields whose form control is a dropdown holding the *strings* 'YES' and 'NO', not a boolean.
*
* They are stored as BOOLEAN, so a saved product comes back from the API as true/false. Patching that
* straight into the form would match no option and leave the dropdown blank, which is why
* `LoanProducts.flattenCbsExtensions()` maps them back to 'YES'/'NO' on the way in.
*/
export const CUSTOM_YES_NO_STRING_FIELDS: string[] = [
'lockChequeInfoEnabled',
'lockPageTwoInfoEnabled',
'isWriteOffAccountType',
'chequeBookIssueEnabled',
'chequeValidationRequired',
'isFirstChequeBookFree',
'allowWithdrawalSlipUsage',
'maintainChequeBookStockRegistry',
'allowCreditByChequeNumber',
'creditChequeValidationRequired',
'accountTypeActiveForTransactions',
'subTypeActivityEnabled',
'customerTerminationAllowed',
'isNonResidentIndianAccount',
'missedCallAlertServiceActive',
'goldLoanPacketNumberWithYearEnabled',
'accountMoveToInoperativeEnabled',
'generateGlDiffEntryEnabled',
'subTypeWiseGlBalanceGenerationEnabled',
'validateBeforeDeletingDebitEntryEnabled',
'rcFileConceptEnabled',
'accountCloseZeroBalanceInOpenDayProcess',
'accountCloseZeroBalanceAtSubledgerTranEntry',
'limitEnhancementAgainstClearingScreenDisplayEnabled',
'advanceInterestParameterInLedgerEnabled',
'vehicleDetailInMasterEnabled',
'deletedChequeIssueEnabled',
'dpReduceInDocumentRenewalDateEnabled',
'dpReduceInRenewalDateEnabled',
'autoCloseAtDayEndEnabled',
'loanRateFromMasterEnabled',
'statementPrintEnabled',
'againstInterestCalculationEnabled',
'dayWiseAgainstClearingInterestRounding',
'considerDebitClosingAmountForAgainstClearing',
'penaltyInterestCalculationEnabled',
'calculateOverdrawnInterestAfterRenewal',
'interestOnCreditBalance',
'penaltyInterestDateDifferentThanRegularInterest',
'useDocumentSubmissionDateForInterest',
'loanManagementSystemRenewalPenaltyActive',
'limitIncreaseUptoInterestAmountInInterestCalculation',
'considerDdPayorderInAgainstClearingInterestCalculation',
'applyUndebitedInterestConceptEnabled',
'allowAgainstClearingEntry',
'allowNoBalanceEntry',
'fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest',
'allowNoBalanceEntryWithPassword',
'undebitedSimpleInterestAccountWise',
'dailyInterestCalculationEnabled',
'nonPerformingAssetPenalInterestRateUpdate',
'fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess',
'atyp14SimpleInterestEnabled',
'checkMasterDetailEditedPassingPendingEnabled',
'creditDebitInterestCalculationSeparateEnabled',
'onTimeInterestProvisionEnabled',
'checkAgainstClearingEnabled',
'gstOnPenaltyEnabled',
'calculateDailyLimitEnhancementTodEnabled',
'fixedDepositOverdraftMultipleInterestRateEnabled',
'foreclosureChargesApplicable',
'isMobileAppEnabled',
'isAtmEnabled',
'atmYearlyChargeEnabled',
'emailActiveEnabled',
'isImpsEnabled',
'isUpiEnabled',
'isPosEnabled',
'isBbpsEnabled',
'isChargeRequiredNeftRtgsCbs',
'isChargeRequiredNeftRtgsMobile',
'calculateEarlyAccountCloseCharge',
'debitBalanceAllow',
'allowDebitTransAfterDueDate',
'branchLevelAccountType',
'chequeBookChargeEnabled',
'chequeReturnChargeEnabled',
'icChargeDebitEnabled',
'cashBundleChargeEnabled',
'autoMinimumBalanceCharge',
'calculateDailyMinimumBalanceCharges',
'minimumBalanceChargeEnabled',
'calculateDailyAgainstClearingCharge'
];
/**
* Sub-groups whose products are current-account-linked and therefore carry channel limits.
* Kept here rather than only on the Settings step so the preview can apply the same rule.
*/
export const CHANNEL_ENABLED_SUB_GROUPS: string[] = [
'cc',
'overdraft'
];
/** Sub-groups that are fixed-tenure EMI term loans, the only ones foreclosure applies to. */
export const TERM_LOAN_SUB_GROUPS: string[] = [
'term',
'personal',
'housing',
'vehicle',
'education',
'business',
'gold_emi'
];
/** Sub-groups issued against pledged gold, which have no cheque book. */
export const NO_CHEQUE_BOOK_SUB_GROUPS: string[] = [
'gold',
'gold_emi'
];
/**
* Whether a legacy section applies to a product, given its sub-group.
*
* The Settings step hides three of these sections outright for some sub-groups -- a Gold Loan has no
* cheque book, no channel limits and no foreclosure charges. The preview was still listing them,
* because the controls keep their defaults whether or not the section was ever rendered, so a Gold
* Loan showed a "Cheque Book Settings" table the officer never saw. Titles are matched against
* CUSTOM_FIELD_SECTIONS, so a rename has to happen in one place.
*
* An unknown or empty sub-group shows everything, matching how the form behaves before a sub-group
* is picked.
*/
export function isCustomSectionApplicable(sectionTitle: string, productSubGroup: string | null | undefined): boolean {
if (!productSubGroup) {
return true;
}
switch (sectionTitle) {
case 'Cheque Book Settings':
return !NO_CHEQUE_BOOK_SUB_GROUPS.includes(productSubGroup);
case 'Channel & Transaction Limits':
case 'POS Facility & ATM CSV Settings':
return CHANNEL_ENABLED_SUB_GROUPS.includes(productSubGroup);
case 'Foreclosure / Early Account Closure':
return TERM_LOAN_SUB_GROUPS.includes(productSubGroup);
default:
return true;
}
}
/** Legacy date fields. Fineract returns a date as [year, month, day], which needs a real Date for the picker. */
export const CUSTOM_DATE_FIELDS: string[] = [
'computerizationDate'
];
/** Every custom field, in form order. Derived from the sections above -- do not maintain separately. */
export const CUSTOM_UI_ONLY_FIELDS: string[] = CUSTOM_FIELD_SECTIONS.flatMap((section) => section.fields);
/**
* Copies a saved product's legacy field values into one step's form.
*
* Every step calls this with its own form and the same product: a field belonging to another step
* has no control here, so it is skipped. That works because the form control names and the field
* names in this file are the same strings -- which is also where the labels and sections above were
* read from -- so there is no per-step mapping to keep in step with the forms.
*
* Written as a loop rather than 220 lines of patchValue because a field added to a step's form later
* is picked up here for free, and a typo cannot silently leave one field unpopulated.
*
* Only real values are applied: a null or absent field is left at whatever default the form set, so
* this can run after a step's own patchValue without blanking anything it deliberately defaulted.
*
* Patched twice, deliberately. Some of these controls drive others through a valueChanges
* subscription -- picking a Product Category auto-fills the four credit bureau account types, and
* the Details sub-group decides which Settings sections are shown. The first pass lets those
* subscriptions run so that dependent state is set up; the second pass repeats the same values with
* events suppressed, so a value the officer actually saved wins over whatever an auto-fill just
* wrote over it. Without the second pass, reopening a Personal Loan product silently reset its
* saved CIBIL account type back to the category default.
*/
export function patchCustomFields(form: UntypedFormGroup, product: any): void {
if (!form || !product) {
return;
}
const apply = (emitEvent: boolean) => {
for (const field of CUSTOM_UI_ONLY_FIELDS) {
const control = form.get(field);
if (!control) {
continue;
}
const value = product[field];
if (value === undefined || value === null) {
continue;
}
control.patchValue(value, { emitEvent });
}
};
apply(true);
apply(false);
}
@@ -8,6 +8,7 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges, inject } from '@angular/core';
import { UntypedFormArray, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { patchCustomFields } from '../../loan-product-custom-fields';
import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
@@ -210,7 +211,14 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
intOverdueReserveAccountId: accountingMappings.intOverdueReserveAccount?.id || '',
undebitedInterestAccountId: accountingMappings.undebitedInterestAccount?.id || '',
penalIntCrGlAccountId: accountingMappings.penalIntCrGlAccount?.id || '',
rdMaturedGlAccountId: accountingMappings.rdMaturedGlAccount?.id || ''
rdMaturedGlAccountId: accountingMappings.rdMaturedGlAccount?.id || '',
glAccountId: accountingMappings.glAccount?.id || '',
icChargeGlAccountId: accountingMappings.icChargeGlAccount?.id || '',
inoperativeGlAccountId: accountingMappings.inoperativeGlAccount?.id || '',
creditBalanceLiabilityGlAccountId: accountingMappings.creditBalanceLiabilityGlAccount?.id || '',
creditBalanceAssetGlAccountId: accountingMappings.creditBalanceAssetGlAccount?.id || '',
overdueAccountsGlAccountId: accountingMappings.overdueAccountsGlAccount?.id || '',
rebateGlAccountId: accountingMappings.rebateGlAccount?.id || ''
});
if (this.loanProductsTemplate.paymentChannelToFundSourceMappings) {
@@ -234,6 +242,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
this.setOtherGlMappings('incomeOtherGlMappings', glMappings.income || [], this.incomeAccountData);
}
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductAccountingForm, this.loanProductsTemplate);
}
public createLoanProductAccountingForm() {
@@ -276,16 +288,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
'incomeFromGoodwillCreditInterestAccountId',
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId'
'glAccountId',
'inoperativeGlAccountId'
];
commonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
@@ -293,6 +299,44 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
}
});
// Accrual-only receivable fields: their input (mifosx-gl-account-selector) only renders
// when isAccrualAccounting() is true, so only require them in that case — otherwise they'd
// be unconditionally required with no visible field to fill them, permanently invalidating
// the form (accountingRule is hard-locked to Cash in this build).
const accrualOnlyFields = [
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId'
];
accrualOnlyFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(
field,
new UntypedFormControl('', this.isAccrualAccounting() ? Validators.required : null)
);
}
});
// Optional custom GL account fields (legacy screenshot fields not marked mandatory) —
// no Validators.required, so mat-form-field does not render a required asterisk for these.
const optionalCommonFields = [
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId',
'icChargeGlAccountId',
'creditBalanceLiabilityGlAccountId',
'creditBalanceAssetGlAccountId',
'overdueAccountsGlAccountId',
'rebateGlAccountId'
];
optionalCommonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(field, new UntypedFormControl(''));
}
});
if (!this.loanProductAccountingForm.contains('advancedAccountingRules')) {
this.loanProductAccountingForm.addControl('advancedAccountingRules', new UntypedFormControl(false));
}
@@ -620,7 +664,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
}
public isAccrualAccounting(): boolean {
const accountingRule = this.loanProductAccountingForm.value.accountingRule;
const accountingRule = this.loanProductAccountingForm.get('accountingRule').value;
return accountingRule === 3 || accountingRule === 4;
}
@@ -708,15 +752,15 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'intReceivablesGlAccountId',
label: 'Int. Receivables GL A/c',
inputLabel: 'Int. Receivables GL A/c',
inputLabel: 'Int Receivables GL A/c',
controlName: 'intReceivablesGlAccountId',
options: this.assetAccountData,
required: false
required: true
},
{
key: 'deafIntPayableGlAccountId',
label: 'Deaf Int. Payble GL A/c',
inputLabel: 'Deaf Int. Payble GL A/c',
inputLabel: 'Deaf Int Payble GL A/c',
controlName: 'deafIntPayableGlAccountId',
options: this.assetAccountData,
required: false
@@ -724,7 +768,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'intOverdueReserveAccountId',
label: 'Int. Overdue Reserve A/c (AS)',
inputLabel: 'Int. Overdue Reserve A/c',
inputLabel: 'Int Overdue Reserve A/c',
controlName: 'intOverdueReserveAccountId',
options: this.assetAccountData,
required: false
@@ -736,6 +780,38 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
controlName: 'rdMaturedGlAccountId',
options: this.assetAccountData,
required: false
},
{
key: 'glAccountId',
label: 'GL A/c No.',
inputLabel: 'GL A/c No',
controlName: 'glAccountId',
options: this.assetAccountData,
required: true
},
{
key: 'inoperativeGlAccountId',
label: 'InOperative GL A/c',
inputLabel: 'InOperative GL A/c',
controlName: 'inoperativeGlAccountId',
options: this.assetAccountData,
required: true
},
{
key: 'creditBalanceAssetGlAccountId',
label: 'Cr.Balance Asset GL A/c',
inputLabel: 'Cr Balance Asset GL A/c',
controlName: 'creditBalanceAssetGlAccountId',
options: this.assetAccountData,
required: false
},
{
key: 'overdueAccountsGlAccountId',
label: 'Overdue Accounts GL A/c No.',
inputLabel: 'Overdue Accounts GL A/c No',
controlName: 'overdueAccountsGlAccountId',
options: this.assetAccountData,
required: false
}
],
otherMappingsControlName: 'assetsOtherGlMappings',
@@ -848,10 +924,18 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'penalIntCrGlAccountId',
label: 'Penal Int. Cr. GL A/c',
inputLabel: 'Penal Int. Cr. GL A/c',
inputLabel: 'Penal Int Cr GL A/c',
controlName: 'penalIntCrGlAccountId',
options: this.incomeAccountData,
required: false
},
{
key: 'icChargeGlAccountId',
label: 'I.C. Charge GL A/c No.',
inputLabel: 'IC Charge GL A/c No',
controlName: 'icChargeGlAccountId',
options: this.incomeAccountData,
required: false
}
],
otherMappingsControlName: 'incomeOtherGlMappings',
@@ -903,6 +987,14 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
options: this.expenseAccountData,
required: true,
deferredBuyDownMerchantOnly: true
},
{
key: 'rebateGlAccountId',
label: 'Rebate GL A/c No.',
inputLabel: 'Rebate GL A/c No',
controlName: 'rebateGlAccountId',
options: this.expenseAccountData,
required: false
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -934,10 +1026,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
{
key: 'intPayableGlAccountId',
label: 'Int. Payable GL A/c',
inputLabel: 'Int. Payable GL A/c',
inputLabel: 'Int Payable GL A/c',
controlName: 'intPayableGlAccountId',
options: this.liabilityAccountData,
required: false
required: true
},
{
key: 'undebitedInterestAccountId',
@@ -946,6 +1038,14 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
controlName: 'undebitedInterestAccountId',
options: this.liabilityAccountData,
required: false
},
{
key: 'creditBalanceLiabilityGlAccountId',
label: 'Cr.Balance Liability GL A/c',
inputLabel: 'Cr Balance Liability GL A/c',
controlName: 'creditBalanceLiabilityGlAccountId',
options: this.liabilityAccountData,
required: false
}
],
otherMappingsControlName: 'liabilitiesOtherGlMappings',
@@ -6,7 +6,11 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<!-- Charges -->
<!--
Charges & Overdue Charges (native Fineract charge-picker sections) are temporarily
disabled — not currently in use (no matching backend charge data / not part of the
active workflow). Re-enable by removing this comment block when needed.
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-receipt"></i>
@@ -89,7 +93,6 @@
</div>
</div>
<!-- Overdue Charges -->
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-exclamation-circle"></i>
@@ -171,3 +174,174 @@
}
</div>
</div>
-->
<!-- Additional Charges (legacy Page 2/Page 4 charge fields) -->
<form [formGroup]="legacyChargesForm">
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-money-check"></i>
<h3>Cheque &amp; Standing Instruction Charges</h3>
</div>
<div class="lpch-section__body lpch-grid-4">
<mat-form-field appearance="outline">
<mat-label>Chq. Book Charge</mat-label>
<mat-select formControlName="chequeBookChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Chq. Return Charge</mat-label>
<mat-select formControlName="chequeReturnChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>I.C. Charge Debit</mat-label>
<mat-select formControlName="icChargeDebitEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cash Bundle Charge</mat-label>
<mat-select formControlName="cashBundleChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cash Bundle Charge With PAN Limit</mat-label>
<input matInput type="number" formControlName="cashBundleChargeWithPanLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Cheque Entry Charge</mat-label>
<input matInput type="number" formControlName="perChequeEntryCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Cheque Entry Service Charge</mat-label>
<input matInput type="number" formControlName="perChequeEntryServiceCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Inward Cheque Entry Charge</mat-label>
<input matInput type="number" formControlName="perInwardChequeEntryCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Per Outward Cheque Entry Charge</mat-label>
<input matInput type="number" formControlName="perOutwardChequeEntryCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Creditor Standing Instr. Fail Charge</mat-label>
<input matInput type="number" formControlName="creditorStandingInstructionFailCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Debtor Standing Instr. Fail Charge</mat-label>
<input matInput type="number" formControlName="debtorStandingInstructionFailCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Calculate Daily Against Clearing Charge</mat-label>
<mat-select formControlName="calculateDailyAgainstClearingCharge">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="lpch-section">
<div class="lpch-section__header">
<i class="fas fa-wallet"></i>
<h3>Minimum Balance Charges</h3>
</div>
<div class="lpch-section__body lpch-grid-4">
<mat-form-field appearance="outline">
<mat-label>Minimum Balance Checking</mat-label>
<mat-select formControlName="minimumBalanceCheckingType">
<mat-option value="STRICT">Strict</mat-option>
<mat-option value="AVERAGE">Average</mat-option>
<mat-option value="LENIENT">Lenient</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Auto Minimum Balance Charge</mat-label>
<mat-select formControlName="autoMinimumBalanceCharge">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Calculate Daily Min. Balance Charges</mat-label>
<mat-select formControlName="calculateDailyMinimumBalanceCharges">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Minimum Balance Charge</mat-label>
<mat-select formControlName="minimumBalanceChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Min. Balance Charge (Rs.)</mat-label>
<input matInput type="number" formControlName="minimumBalanceChargeAmount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Min. Balance Charge For Cheque Issue (Rs.)</mat-label>
<input matInput type="number" formControlName="minimumBalanceChargeForChequeIssue" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Min. Balance Charge Method</mat-label>
<mat-select formControlName="minimumBalanceChargeMethod">
<mat-option value="FLAT">Flat</mat-option>
<mat-option value="SLAB">Slab</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Months After Min. Balance Charge Calculate</mat-label>
<input matInput type="number" formControlName="monthsAfterMinBalanceChargeCalculate" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Cheque Issue</mat-label>
<input matInput type="number" formControlName="forChequeIssue" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Cheque Issue (Avg.)</mat-label>
<input matInput type="number" formControlName="forChequeIssueAvg" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Withdrawal Slip</mat-label>
<input matInput type="number" formControlName="forWithdrawalSlip" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>For Withdrawal Slip (Avg.)</mat-label>
<input matInput type="number" formControlName="forWithdrawalSlipAvg" />
</mat-form-field>
</div>
</div>
</form>
@@ -88,3 +88,27 @@ $gray-900: #202124;
.lpch-table {
width: 100%;
}
.lpch-section__body {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: start;
gap: 18px 16px;
padding: 20px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lpch-section__body.lpch-grid-4 {
grid-template-columns: repeat(4, 1fr);
@media (width <= 1100px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
@@ -7,7 +7,8 @@
*/
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { UntypedFormControl, UntypedFormBuilder, UntypedFormGroup, ReactiveFormsModule } from '@angular/forms';
import { patchCustomFields } from '../../loan-product-custom-fields';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
@@ -51,12 +52,16 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRow,
ChargesFilterPipe,
ChargesPenaltyFilterPipe,
FormatNumberPipe
FormatNumberPipe,
ReactiveFormsModule
]
})
export class LoanProductChargesStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private formBuilder = inject(UntypedFormBuilder);
legacyChargesForm: UntypedFormGroup;
@Input() loanProductsTemplate: any;
@Input() currencyCode: UntypedFormControl;
@@ -76,6 +81,48 @@ export class LoanProductChargesStepComponent implements OnInit {
pristine = true;
constructor() {
this.createLegacyChargesForm();
}
createLegacyChargesForm() {
this.legacyChargesForm = this.formBuilder.group({
// Cheque-related charges (legacy "A/c Mas Scr2 Para")
chequeBookChargeEnabled: ['NO'],
chequeReturnChargeEnabled: ['NO'],
icChargeDebitEnabled: ['NO'],
cashBundleChargeEnabled: ['NO'],
cashBundleChargeWithPanLimit: [''],
// Cheque transaction charges (legacy Page 4)
perChequeEntryCharge: [''],
perChequeEntryServiceCharge: [''],
perInwardChequeEntryCharge: [''],
perOutwardChequeEntryCharge: [''],
// Standing Instruction fail charges (legacy Page 4)
creditorStandingInstructionFailCharge: [''],
debtorStandingInstructionFailCharge: [''],
// Minimum Balance Information (legacy Page 2 + Page 4)
minimumBalanceCheckingType: [''],
autoMinimumBalanceCharge: [''],
calculateDailyMinimumBalanceCharges: ['NO'],
minimumBalanceChargeEnabled: ['NO'],
minimumBalanceChargeAmount: [''],
minimumBalanceChargeForChequeIssue: [''],
minimumBalanceChargeMethod: [''],
monthsAfterMinBalanceChargeCalculate: [''],
forChequeIssue: [''],
forChequeIssueAvg: [''],
forWithdrawalSlip: [''],
forWithdrawalSlipAvg: [''],
// Against Clearing charge (legacy Page 4)
calculateDailyAgainstClearingCharge: ['NO']
});
}
ngOnInit() {
this.chargeData = this.loanProductsTemplate.chargeOptions;
this.overdueChargeData = this.loanProductsTemplate.penaltyOptions
@@ -89,6 +136,10 @@ export class LoanProductChargesStepComponent implements OnInit {
this.currencyCode.valueChanges.subscribe(() => (this.chargesDataSource = []));
this.multiDisburseLoan.valueChanges.subscribe(() => (this.chargesDataSource = []));
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.legacyChargesForm, this.loanProductsTemplate);
}
addCharge(charge: any) {
@@ -112,7 +163,8 @@ export class LoanProductChargesStepComponent implements OnInit {
get loanProductCharges() {
return {
charges: this.chargesDataSource
charges: this.chargesDataSource,
...this.legacyChargesForm.value
};
}
}
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../../../../.."
},
{
"path": "../../../../../../../cbsnexora-backend"
}
],
"settings": {}
}
@@ -7,40 +7,144 @@
-->
<form [formGroup]="loanProductDetailsForm">
<!-- Product Classification + General Information — merged single section, 2-column layout -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
<h3>General Information</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<div class="lpd-two-col">
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Account Type Group</mat-label>
<mat-select formControlName="productTypeGroup">
@for (group of productTypeGroups; track group.id) {
<mat-option [value]="group.id">{{ group.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (availableSubGroups.length) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Product Type Sub Group</mat-label>
<mat-select formControlName="productTypeSubGroup">
@for (subGroup of availableSubGroups; track subGroup.id) {
<mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>A/c. Type Description</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>A/c Type Code</mat-label>
<input matInput formControlName="accountTypeCode" maxlength="2" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>MICR Trn. Code</mat-label>
<input matInput formControlName="micrTransactionCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Reserved Code Srno For ECS</mat-label>
<input matInput formControlName="reservedEcsSerialNumber" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Reverse ECS No.</mat-label>
<input matInput formControlName="reverseEcsNumber" />
</mat-form-field>
</div>
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Lock Cheque Info</mat-label>
<mat-select formControlName="lockChequeInfoEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Lock Page 2 Info</mat-label>
<mat-select formControlName="lockPageTwoInfoEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="computerizationDatePicker.open()">
<mat-label>Computerization Date</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="computerizationDatePicker"
formControlName="computerizationDate"
/>
<mat-datepicker-toggle matSuffix [for]="computerizationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #computerizationDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>PFMS Account Type</mat-label>
<input matInput formControlName="pfmsAccountType" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Chq. Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>POS A/c Type Description</mat-label>
<input matInput formControlName="posAccountTypeDescription" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Renewal Dt. Add Months</mat-label>
<input matInput type="number" formControlName="renewalDateAddMonths" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Renewal Grace Days</mat-label>
<input matInput type="number" formControlName="renewalGraceDays" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Doc. Renewal Dt. Add Months</mat-label>
<input matInput type="number" formControlName="documentRenewalDateAddMonths" />
</mat-form-field>
</div>
</div>
</div>
@@ -97,86 +201,94 @@
<i class="fas fa-percentage"></i>
<h3>Interest Information</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<div class="lpd-two-col">
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
<div class="lpd-two-col__column">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="lpd-info-note">
<i class="fas fa-circle-info"></i>
GL Account mappings for these fields (Int. Receivables, Penal Int. Cr., RD Matured, Int. Payable, Deaf Int.
Payable, Int. Overdue Reserve, UnDebited Interest) are available in the <strong>Accounting</strong> tab.
</div>
</div>
<!-- Product Classification + GL Account — combined section -->
<!-- PPS Limit & Write Off Details (legacy Page 1 bottom fields) -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-sitemap"></i>
<h3>Product Classification</h3>
<i class="fas fa-ban"></i>
<h3>PPS Limit &amp; Write Off Details</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Product Type Group</mat-label>
<mat-select formControlName="productTypeGroup">
@for (group of productTypeGroups; track group.id) {
<mat-option [value]="group.id">{{ group.label }}</mat-option>
}
<mat-label>PPS Limit</mat-label>
<input matInput type="number" formControlName="ppsLimit" />
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Is this Actyp Write Off?</mat-label>
<mat-select formControlName="isWriteOffAccountType">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
@if (loanProductDetailsForm.get('isWriteOffAccountType')?.value === 'YES') {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label>
<mat-select formControlName="glAccount">
@for (acc of availableGlAccounts; track acc.id) {
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option>
}
</mat-select>
<mat-error>GL Account is required</mat-error>
<mat-label>Write Off Actyp</mat-label>
<input matInput formControlName="writeOffAccountTypeCode" />
</mat-form-field>
}
</div>
@@ -46,9 +46,10 @@ $gray-900: #202124;
.lpd-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-items: start;
gap: 18px 16px;
padding: 20px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
@@ -57,6 +58,7 @@ $gray-900: #202124;
.lpd-field {
width: 100%;
min-width: 0;
&--full {
grid-column: 1 / -1;
@@ -69,6 +71,43 @@ $gray-900: #202124;
padding: 4px 0;
}
// Two independent vertical columns of fields, side by side
.lpd-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 40px;
padding: 20px 24px;
align-items: start;
@media (width <= 700px) {
grid-template-columns: 1fr;
}
&__column {
display: flex;
flex-direction: column;
}
}
.lpd-info-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 24px 16px;
padding: 10px 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
font-size: 12.5px;
color: #1e3a8a;
line-height: 1.5;
i {
color: #2563eb;
margin-top: 2px;
}
}
// GL Section
.lpd-gl-section {
border-top: 2px solid #e0f2fe;
@@ -7,8 +7,9 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, inject } from '@angular/core';
import { Component, OnInit, Input, Output, EventEmitter, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { CUSTOM_DATE_FIELDS, patchCustomFields } from '../../loan-product-custom-fields';
import { Dates } from 'app/core/utils/dates';
/** Custom Services */
@@ -33,6 +34,7 @@ export class LoanProductDetailsStepComponent implements OnInit {
private settingsService = inject(SettingsService);
@Input() loanProductsTemplate: any;
@Output() productSubGroupChange = new EventEmitter<string>();
loanProductDetailsForm: UntypedFormGroup;
@@ -49,6 +51,10 @@ export class LoanProductDetailsStepComponent implements OnInit {
this.createLoanProductDetailsForm();
}
assetAccountOptions: any[] = [];
incomeAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
ngOnInit() {
this.loanProductDetailsForm.patchValue({
name: this.loanProductsTemplate.name,
@@ -58,10 +64,6 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
});
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
this.loanProductDetailsForm.patchValue({
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
@@ -70,66 +72,63 @@ export class LoanProductDetailsStepComponent implements OnInit {
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
});
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.incomeAccountOptions = mappingOptions.incomeAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
const accountingMappings = this.loanProductsTemplate?.accountingMappings;
if (accountingMappings) {
this.loanProductDetailsForm.patchValue({
intReceivablesGlAccountId: accountingMappings.intReceivablesGlAccount?.id || '',
penalIntCrGlAccountId: accountingMappings.penalIntCrGlAccount?.id || '',
rdMaturedGlAccountId: accountingMappings.rdMaturedGlAccount?.id || '',
intPayableGlAccountId: accountingMappings.intPayableGlAccount?.id || '',
deafIntPayableGlAccountId: accountingMappings.deafIntPayableGlAccount?.id || '',
intOverdueReserveAccountId: accountingMappings.intOverdueReserveAccount?.id || '',
undebitedInterestAccountId: accountingMappings.undebitedInterestAccount?.id || ''
});
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductDetailsForm, this.loanProductsTemplate);
}
/** Product Classification data */
readonly productTypeGroups = [
{ id: 'loans', label: 'Loan' },
{ id: 'advanced', label: 'Advanced' }
{ id: 'advances', label: 'Advances A/C' },
{ id: 'loans', label: 'Loans A/C' }
];
readonly productTypeSubGroups: Record<string, { id: string; label: string }[]> = {
loans: [
{ id: 'emi', label: 'EMI' },
{ id: 'cc', label: 'CC (Cash Credit)' },
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'warehouse', label: 'Warehouse' },
{ id: 'gold', label: 'Gold' }
// Renewable, Drawing-Power/limit-based revolving credit facilities
advances: [
{ id: 'cc', label: 'Cash Credit (CC)' },
{ id: 'overdraft', label: 'Overdraft (OD)' },
{ id: 'gold', label: 'Gold Loan' },
{ id: 'warehouse', label: 'Warehouse Receipt / Pledge Loan' },
{ id: 'kcc', label: 'Kisan Credit Card (KCC)' },
{ id: 'bills', label: 'Bills Purchased / Discounted' }
],
advanced: [
{ id: 'fd', label: 'FD (Fixed Deposit)' },
{ id: 'rd', label: 'RD (Recurring Deposit)' }
// Fixed disbursement, fixed tenure, EMI-based term credit facilities
loans: [
{ id: 'term', label: 'Term Loan / Long Term Loan' },
{ id: 'personal', label: 'Personal Loan' },
{ id: 'housing', label: 'Housing Loan' },
{ id: 'vehicle', label: 'Vehicle Loan' },
{ id: 'education', label: 'Education Loan' },
{ id: 'business', label: 'Business / MSME Term Loan' },
{ id: 'gold_emi', label: 'Gold EMI Loan' }
]
};
/** Default GL per product type group */
private readonly glDefaultMap: Record<string, string> = {
loans: 'asset',
advanced: 'asset'
};
get availableSubGroups() {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return group ? (this.productTypeSubGroups[group] ?? []) : [];
}
get glOptions(): string[] {
return [
'asset',
'liability'
];
}
assetAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
get availableGlAccounts(): any[] {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
if (type === 'asset') return this.assetAccountOptions;
if (type === 'liability') return this.liabilityAccountOptions;
return [];
}
get glClassificationLabel(): string {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
return type === 'liability' ? 'Liability' : 'Asset';
}
get selectedGroupLabel(): string {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
}
readonly yesNoOptions = [
{ value: true, label: 'YES' },
{ value: false, label: 'NO' }
@@ -150,28 +149,46 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: [''],
productTypeGroup: [''],
productTypeSubGroup: [''],
glClassification: ['asset'],
glAccount: [''],
debitIntCalculation: [false],
separatePenalInterest: [false],
creditIntCalculation: [false],
subAccountTypeBalance: [false],
finalIntRounding: [false],
roundingUpTo: [null]
roundingUpTo: [null],
// General Information (legacy Page 1 header fields)
accountTypeCode: [''],
micrTransactionCode: [''],
reservedEcsSerialNumber: [''],
reverseEcsNumber: [''],
lockChequeInfoEnabled: ['NO'],
lockPageTwoInfoEnabled: ['NO'],
computerizationDate: [''],
chequePrintDescription: [''],
posAccountTypeDescription: [''],
pfmsAccountType: [''],
renewalDateAddMonths: [''],
renewalGraceDays: [''],
documentRenewalDateAddMonths: [''],
// PPS Limit & Write Off Details
ppsLimit: [''],
isWriteOffAccountType: ['NO'],
writeOffAccountTypeCode: ['']
});
// Auto-set GL based on product type group
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => {
const defaultVal = this.glDefaultMap[group] ?? 'asset';
this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal);
this.loanProductDetailsForm.get('glAccount')?.setValue('');
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroup: string) => {
this.productSubGroupChange.emit(subGroup);
});
}
get loanProductDetails() {
const loanProductDetailsFormData = this.loanProductDetailsForm.value;
const prevStartDate: Date = this.loanProductDetailsForm.value.startDate;
const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate;
// getRawValue() rather than value: it returns a fresh object, so the date reformatting below
// cannot write back into the form's own cached value, and any control disabled later still
// reaches the preview step instead of silently vanishing from it.
const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue();
const prevStartDate: Date = loanProductDetailsFormData.startDate;
const prevCloseDate: Date = loanProductDetailsFormData.closeDate;
const dateFormat = this.settingsService.dateFormat;
if (loanProductDetailsFormData.startDate instanceof Date) {
loanProductDetailsFormData.startDate = this.dateUtils.formatDate(prevStartDate, dateFormat) || '';
@@ -179,6 +196,17 @@ export class LoanProductDetailsStepComponent implements OnInit {
if (loanProductDetailsFormData.closeDate instanceof Date) {
loanProductDetailsFormData.closeDate = this.dateUtils.formatDate(prevCloseDate, dateFormat) || '';
}
// The legacy date fields need the same treatment: their control holds a Date, but the API parses
// them against the dateFormat sent alongside, and rejects the raw value outright. Driven off
// CUSTOM_DATE_FIELDS rather than named here, so a legacy date added later is covered too.
for (const field of CUSTOM_DATE_FIELDS) {
const value = loanProductDetailsFormData[field];
if (value instanceof Date) {
loanProductDetailsFormData[field] = this.dateUtils.formatDate(value, dateFormat) || '';
}
}
return loanProductDetailsFormData;
}
}
@@ -235,209 +235,361 @@
</div>
</div>
<!-- Cheque Book Settings -->
<div class="lps-section">
<div class="lps-section__header">
<i class="fas fa-money-check lps-section__icon"></i>
<h3 class="lps-section__title">Cheque Book Settings</h3>
</div>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Cheque Book Issue Enabled</mat-label>
<mat-select formControlName="chequeBookIssueEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-accordion class="lps-accordion" multi="true">
@if (showChequeBookSettings) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-money-check lps-section__icon"></i>
Cheque Book Settings
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Cheque Book Issue Enabled</mat-label>
<mat-select formControlName="chequeBookIssueEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Validation Required</mat-label>
<mat-select formControlName="chequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Validation Required</mat-label>
<mat-select formControlName="chequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is First Cheque Book Free</mat-label>
<mat-select formControlName="isFirstChequeBookFree">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is First Cheque Book Free</mat-label>
<mat-select formControlName="isFirstChequeBookFree">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Printing Account Type</mat-label>
<mat-select formControlName="chequePrintingAccountType">
<mat-option value="SAVINGS_BANK">Savings Bank</mat-option>
<mat-option value="CURRENT_ACCOUNT">Current Account</mat-option>
<mat-option value="CASH_CREDIT">Cash Credit</mat-option>
<mat-option value="OVERDRAFT">Overdraft</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Printing Account Type</mat-label>
<mat-select formControlName="chequePrintingAccountType">
<mat-option value="SAVINGS_BANK">Savings Bank</mat-option>
<mat-option value="CURRENT_ACCOUNT">Current Account</mat-option>
<mat-option value="CASH_CREDIT">Cash Credit</mat-option>
<mat-option value="OVERDRAFT">Overdraft</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Withdrawal Slip Usage</mat-label>
<mat-select formControlName="allowWithdrawalSlipUsage">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Withdrawal Slip Usage</mat-label>
<mat-select formControlName="allowWithdrawalSlipUsage">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maintain Cheque Book Stock Registry</mat-label>
<mat-select formControlName="maintainChequeBookStockRegistry">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maintain Cheque Book Stock Registry</mat-label>
<mat-select formControlName="maintainChequeBookStockRegistry">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Free Cheque Books Per Year</mat-label>
<input type="number" matInput formControlName="freeChequeBooksPerYear" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Free Cheque Books Per Year</mat-label>
<input type="number" matInput formControlName="freeChequeBooksPerYear" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Credit By Cheque Number</mat-label>
<mat-select formControlName="allowCreditByChequeNumber">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Allow Credit By Cheque Number</mat-label>
<mat-select formControlName="allowCreditByChequeNumber">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Cheque Validation Required</mat-label>
<mat-select formControlName="creditChequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Cheque Validation Required</mat-label>
<mat-select formControlName="creditChequeValidationRequired">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Grace Days For Stock Statement</mat-label>
<input type="number" matInput formControlName="graceDaysForStockStatement" />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline">
<mat-label>Grace Days For Stock Statement</mat-label>
<input type="number" matInput formControlName="graceDaysForStockStatement" />
</mat-form-field>
</div>
</mat-expansion-panel>
}
<!-- Additional Product Settings -->
<div class="lps-section">
<div class="lps-section__header">
<i class="fas fa-cogs lps-section__icon"></i>
<h3 class="lps-section__title">Additional Product Settings</h3>
</div>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Account Type Active For Transactions</mat-label>
<mat-select formControlName="accountTypeActiveForTransactions">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-cogs lps-section__icon"></i>
Additional Product Settings
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Account Type Active For Transactions</mat-label>
<mat-select formControlName="accountTypeActiveForTransactions">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub Type Activity Enabled</mat-label>
<mat-select formControlName="subTypeActivityEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub Type Activity Enabled</mat-label>
<mat-select formControlName="subTypeActivityEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Opening Charges</mat-label>
<input type="number" matInput formControlName="accountOpeningCharges" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Opening Charges</mat-label>
<input type="number" matInput formControlName="accountOpeningCharges" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maximum Gold Loan Limit</mat-label>
<input type="number" matInput formControlName="maximumGoldLoanLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Maximum Gold Loan Limit</mat-label>
<input type="number" matInput formControlName="maximumGoldLoanLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Last Account Number Opened</mat-label>
<input type="text" matInput formControlName="lastAccountNumberOpened" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Gold Loan [Packet No.] With Year?</mat-label>
<mat-select formControlName="goldLoanPacketNumberWithYearEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Customer Termination Allowed</mat-label>
<mat-select formControlName="customerTerminationAllowed">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Last Account Number Opened</mat-label>
<input type="text" matInput formControlName="lastAccountNumberOpened" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Charges For Cash Credit Above Limit</mat-label>
<input type="number" matInput formControlName="chargesForCashCreditAboveLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Customer Termination Allowed</mat-label>
<mat-select formControlName="customerTerminationAllowed">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Death Interest GL Account</mat-label>
<input type="text" matInput formControlName="deathInterestGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Charges For Cash Credit Above Limit</mat-label>
<input type="number" matInput formControlName="chargesForCashCreditAboveLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Starting Ledger Number</mat-label>
<input type="number" matInput formControlName="startingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Death Interest GL Account</mat-label>
<input type="text" matInput formControlName="deathInterestGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Ending Ledger Number</mat-label>
<input type="number" matInput formControlName="endingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Starting Ledger Number</mat-label>
<input type="number" matInput formControlName="startingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Current Ledger Number</mat-label>
<input type="number" matInput formControlName="currentLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Ending Ledger Number</mat-label>
<input type="number" matInput formControlName="endingLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Remittance Account Number</mat-label>
<input type="text" matInput formControlName="remittanceAccountNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Current Ledger Number</mat-label>
<input type="number" matInput formControlName="currentLedgerNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Non Resident Indian Account</mat-label>
<mat-select formControlName="isNonResidentIndianAccount">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Remittance Account Number</mat-label>
<input type="text" matInput formControlName="remittanceAccountNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Debit Clearing GL Account</mat-label>
<input type="text" matInput formControlName="debitClearingGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Non Resident Indian Account</mat-label>
<mat-select formControlName="isNonResidentIndianAccount">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Missed Call Alert Service Active</mat-label>
<mat-select formControlName="missedCallAlertServiceActive">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Debit Clearing GL Account</mat-label>
<input type="text" matInput formControlName="debitClearingGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Charge GL Account</mat-label>
<input type="text" matInput formControlName="standingInstructionChargeGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Missed Call Alert Service Active</mat-label>
<mat-select formControlName="missedCallAlertServiceActive">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Debit Charge Skip Option</mat-label>
<mat-select formControlName="standingInstructionDebitChargeSkipOption">
<mat-option value="SKIP_ALWAYS">Skip Always</mat-option>
<mat-option value="SKIP_ON_FIRST_FAILURE">Skip on First Failure</mat-option>
<mat-option value="NEVER_SKIP">Never Skip</mat-option>
<mat-option value="RETRY_AFTER_24_HOURS">Retry after 24 Hours</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Charge GL Account</mat-label>
<input type="text" matInput formControlName="standingInstructionChargeGLAccount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Opening Balance Multiplier For Remittance Charges</mat-label>
<input type="number" matInput formControlName="openingBalanceMultiplierForRemittanceCharges" />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline">
<mat-label>Standing Instruction Debit Charge Skip Option</mat-label>
<mat-select formControlName="standingInstructionDebitChargeSkipOption">
<mat-option value="SKIP_ALWAYS">Skip Always</mat-option>
<mat-option value="SKIP_ON_FIRST_FAILURE">Skip on First Failure</mat-option>
<mat-option value="NEVER_SKIP">Never Skip</mat-option>
<mat-option value="RETRY_AFTER_24_HOURS">Retry after 24 Hours</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Opening Balance Multiplier For Remittance Charges</mat-label>
<input type="number" matInput formControlName="openingBalanceMultiplierForRemittanceCharges" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>A/c Move To InOperative A/c</mat-label>
<mat-select formControlName="accountMoveToInoperativeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Generate GL Diff Entry</mat-label>
<mat-select formControlName="generateGlDiffEntryEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub Type Wise GL Balance Gen</mat-label>
<mat-select formControlName="subTypeWiseGlBalanceGenerationEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Want To Validation While Deleting Debit Entry</mat-label>
<mat-select formControlName="validateBeforeDeletingDebitEntryEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Want To RC File Concept</mat-label>
<mat-select formControlName="rcFileConceptEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>A/c Close With Zero Bal. In Open Day Process</mat-label>
<mat-select formControlName="accountCloseZeroBalanceInOpenDayProcess">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>A/c Close When Bal. Zero At Subled Tran Entry</mat-label>
<mat-select formControlName="accountCloseZeroBalanceAtSubledgerTranEntry">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Lmt.Enh/Ag.Clg Screen Display</mat-label>
<mat-select formControlName="limitEnhancementAgainstClearingScreenDisplayEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Advance Int. Param In Ledger</mat-label>
<mat-select formControlName="advanceInterestParameterInLedgerEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Vehicle Detail In Master</mat-label>
<mat-select formControlName="vehicleDetailInMasterEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Deleted Chq. Issue</mat-label>
<mat-select formControlName="deletedChequeIssueEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>DP Reduce In Doc. Renewal Date</mat-label>
<mat-select formControlName="dpReduceInDocumentRenewalDateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>DP Reduce In Renewal Date</mat-label>
<mat-select formControlName="dpReduceInRenewalDateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Auto Close Day-End</mat-label>
<mat-select formControlName="autoCloseAtDayEndEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Loan Rate From Master</mat-label>
<mat-select formControlName="loanRateFromMasterEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Short Name</mat-label>
<input type="text" matInput formControlName="chequeShortName" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Statement Print</mat-label>
<mat-select formControlName="statementPrintEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NESL Code</mat-label>
<input type="text" matInput formControlName="neslCode" />
</mat-form-field>
</div>
</mat-expansion-panel>
</mat-accordion>
<!-- Interest Related Parameters (Page 3) -->
<div class="lps-section">
@@ -754,6 +906,85 @@
<mat-label>Add Penal Interest Rate</mat-label>
<input type="number" matInput formControlName="addPenalInterestRate" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Atyp14 Simple Int.</mat-label>
<mat-select formControlName="atyp14SimpleInterestEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>FDOD Margin Type</mat-label>
<input type="text" matInput formControlName="fixedDepositOverdraftMarginType" />
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>Check Master Detail Edited (Passing Pend.)</mat-label>
<mat-select formControlName="checkMasterDetailEditedPassingPendingEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>Credit/Debit Int. Calc. Separate</mat-label>
<mat-select formControlName="creditDebitInterestCalculationSeparateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>On Time Interest Provision</mat-label>
<mat-select formControlName="onTimeInterestProvisionEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Check Against Clearing</mat-label>
<mat-select formControlName="checkAgainstClearingEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>GST On Penalty</mat-label>
<mat-select formControlName="gstOnPenaltyEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Penalty Int. Grace Days</mat-label>
<input type="number" min="0" matInput formControlName="penaltyInterestGraceDays" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Over Drawn Int. Rate</mat-label>
<input type="number" matInput formControlName="overdrawnInterestRate" />
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>Calculate Daily Limit Enhancement (TOD)</mat-label>
<mat-select formControlName="calculateDailyLimitEnhancementTodEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lps-span-2">
<mat-label>FDOD Multiple Int. Rate</mat-label>
<mat-select formControlName="fixedDepositOverdraftMultipleInterestRateEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
@@ -801,6 +1032,306 @@
</div>
</div>
<mat-accordion class="lps-accordion" multi="true">
@if (showChannelLimits) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-network-wired lps-section__icon"></i>
Channel &amp; Transaction Limits
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Is Mobile App?</mat-label>
<mat-select formControlName="isMobileAppEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is ATM?</mat-label>
<mat-select formControlName="isAtmEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.isAtmEnabled === 'YES') {
<mat-form-field appearance="outline">
<mat-label>ATM Yearly Charge?</mat-label>
<mat-select formControlName="atmYearlyChargeEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>ATM Min. Balance</mat-label>
<input type="number" matInput formControlName="atmMinimumBalance" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Email Active?</mat-label>
<mat-select formControlName="emailActiveEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is IMPS?</mat-label>
<mat-select formControlName="isImpsEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is UPI?</mat-label>
<mat-select formControlName="isUpiEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is POS?</mat-label>
<mat-select formControlName="isPosEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is BBPS?</mat-label>
<mat-select formControlName="isBbpsEnabled">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Inter Bank Limit (Per Day)</mat-label>
<input type="number" matInput formControlName="interBankLimitPerDay" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Inter Bank Limit (Per Transaction)</mat-label>
<input type="number" matInput formControlName="interBankLimitPerTransaction" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT/RTGS Limit (Per Day)</mat-label>
<input type="number" matInput formControlName="neftRtgsLimitPerDay" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT/RTGS Limit (Per Transaction)</mat-label>
<input type="number" matInput formControlName="neftRtgsLimitPerTransaction" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT Cutoff Limit</mat-label>
<input type="number" matInput formControlName="neftCutoffLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>RTGS Cutoff Limit</mat-label>
<input type="number" matInput formControlName="rtgsCutoffLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>NEFT/RTGS Min. Balance</mat-label>
<input type="number" matInput formControlName="neftRtgsMinimumBalance" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Charge Req. NEFT/RTGS (CBS)?</mat-label>
<mat-select formControlName="isChargeRequiredNeftRtgsCbs">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Is Charge Req. NEFT/RTGS (Mobile)?</mat-label>
<mat-select formControlName="isChargeRequiredNeftRtgsMobile">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.isImpsEnabled === 'YES') {
<mat-form-field appearance="outline">
<mat-label>IMPS Limit (Per Day)</mat-label>
<input type="number" matInput formControlName="impsLimitPerDay" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Limit (Per Transaction)</mat-label>
<input type="number" matInput formControlName="impsLimitPerTransaction" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Limit (Per Month)</mat-label>
<input type="number" matInput formControlName="impsLimitPerMonth" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Min. Limit</mat-label>
<input type="number" matInput formControlName="impsMinLimit" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>IMPS Monthly Transaction Count</mat-label>
<input type="number" matInput formControlName="impsMonthlyTransactionCount" />
</mat-form-field>
}
</div>
</mat-expansion-panel>
}
@if (
showChannelLimits &&
(loanProductSettingsForm.value.isPosEnabled === 'YES' || loanProductSettingsForm.value.isAtmEnabled === 'YES')
) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-credit-card lps-section__icon"></i>
POS Facility &amp; ATM CSV Settings
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (ATM)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitAtm" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (ATM)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitAtm" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (POS)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitPos" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (POS)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitPos" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (ECOM)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitEcom" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (ECOM)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitEcom" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Per Transaction Limit (UPI)</mat-label>
<input type="number" matInput formControlName="maxPerTransactionLimitUpi" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Day Transaction Limit (UPI)</mat-label>
<input type="number" matInput formControlName="maxDayTransactionLimitUpi" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>ATM CSV Product Code</mat-label>
<input type="text" matInput formControlName="atmCsvProductCode" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>ATM CSV Type Code</mat-label>
<input type="text" matInput formControlName="atmCsvTypeCode" />
</mat-form-field>
</div>
</mat-expansion-panel>
}
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-clock lps-section__icon"></i>
Auto Interest Post Parameters
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Auto Int. Post Type</mat-label>
<mat-select formControlName="autoInterestPostType">
<mat-option value="MONTHLY">Monthly</mat-option>
<mat-option value="QUARTERLY">Quarterly</mat-option>
<mat-option value="HALF_YEARLY">Half Yearly</mat-option>
<mat-option value="YEARLY">Yearly</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Auto Int. Post When</mat-label>
<mat-select formControlName="autoInterestPostWhen">
<mat-option value="MONTH_END">Month End</mat-option>
<mat-option value="FIX_DAY">Fix Day</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.autoInterestPostWhen === 'FIX_DAY') {
<mat-form-field appearance="outline">
<mat-label>Fix Day</mat-label>
<input type="number" min="1" max="31" matInput formControlName="autoInterestPostFixDay" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>Auto Last Int. Date</mat-label>
<input type="text" matInput formControlName="autoLastInterestDate" placeholder="DD/MM/YYYY" />
</mat-form-field>
</div>
</mat-expansion-panel>
@if (showForeclosureSettings) {
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<i class="fas fa-door-closed lps-section__icon"></i>
Foreclosure / Early Account Closure
</mat-panel-title>
</mat-expansion-panel-header>
<div class="lps-section__body lps-grid-4">
<mat-form-field appearance="outline">
<mat-label>Calculate Early A/c Close Charge?</mat-label>
<mat-select formControlName="calculateEarlyAccountCloseCharge">
<mat-option value="YES">YES</mat-option>
<mat-option value="NO">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductSettingsForm.value.calculateEarlyAccountCloseCharge === 'YES') {
<mat-form-field appearance="outline">
<mat-label>Early A/c Close Charge</mat-label>
<input type="number" matInput formControlName="earlyAccountCloseCharge" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Early A/c Close Period (In Months)</mat-label>
<input type="number" matInput formControlName="earlyAccountClosePeriodInMonths" />
</mat-form-field>
}
</div>
</mat-expansion-panel>
}
</mat-accordion>
<!-- Credit Bureau Settings (CIBIL) -->
<div class="lps-section">
<div class="lps-section__header">
@@ -883,6 +1414,11 @@
<input type="number" min="0" matInput formControlName="graceOnInterestPayment" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Grace Days After Overdue For Moratorium A/c</mat-label>
<input type="number" min="0" matInput formControlName="graceDaysAfterOverdueForMoratorium" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Delinquency Bucket' | translate }}</mat-label>
<mat-select formControlName="delinquencyBucketId">
@@ -50,8 +50,9 @@
&__body {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
align-items: start;
gap: 18px 16px;
padding: 20px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
@@ -83,6 +84,26 @@
grid-column: span 3;
}
.lps-narrow {
max-width: 160px;
}
.lps-accordion {
margin-top: 16px;
.lps-section__body {
padding: 16px 24px 20px;
}
mat-panel-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 600;
}
}
.lps-checkbox-row {
display: flex;
align-items: center;
@@ -15,12 +15,25 @@ import {
ReactiveFormsModule
} from '@angular/forms';
import { LoanProducts } from '../../loan-products';
import {
CHANNEL_ENABLED_SUB_GROUPS,
CUSTOM_UI_ONLY_FIELDS,
NO_CHEQUE_BOOK_SUB_GROUPS,
TERM_LOAN_SUB_GROUPS,
patchCustomFields
} from '../../loan-product-custom-fields';
import { GlobalConfiguration } from 'app/system/configurations/global-configurations-tab/configuration.model';
import { CodeName, OptionData, StringEnumOptionData } from 'app/shared/models/option-data.model';
import { ProcessingStrategyService } from '../../services/processing-strategy.service';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIconButton } from '@angular/material/button';
import {
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
} from '@angular/material/expansion';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -34,6 +47,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatTooltip,
MatCheckbox,
MatIconButton,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle,
FaIconComponent,
ReactiveFormsModule
]
@@ -45,8 +62,27 @@ export class LoanProductSettingsStepComponent implements OnInit {
@Input() toEdit: boolean;
@Input() loanProductsTemplate: any;
@Input() isLinkedToFloatingInterestRates: UntypedFormControl;
@Input() productSubGroup: string;
@Output() advancePaymentStrategy = new EventEmitter<string>();
// The sub-group lists live in loan-product-custom-fields so the preview step can apply the same
// rule: it renders these sections from the form's values, and without the shared list it listed
// sections this step had hidden.
readonly channelEnabledSubGroups = CHANNEL_ENABLED_SUB_GROUPS;
readonly termLoanSubGroups = TERM_LOAN_SUB_GROUPS;
get showChannelLimits(): boolean {
return !this.productSubGroup || CHANNEL_ENABLED_SUB_GROUPS.includes(this.productSubGroup);
}
get showChequeBookSettings(): boolean {
return !this.productSubGroup || !NO_CHEQUE_BOOK_SUB_GROUPS.includes(this.productSubGroup);
}
get showForeclosureSettings(): boolean {
return !this.productSubGroup || TERM_LOAN_SUB_GROUPS.includes(this.productSubGroup);
}
loanProductSettingsForm: UntypedFormGroup;
amortizationTypeData: any;
@@ -247,6 +283,10 @@ export class LoanProductSettingsStepComponent implements OnInit {
}
});
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductSettingsForm, this.loanProductsTemplate);
}
createLoanProductSettingsForm() {
@@ -366,6 +406,24 @@ export class LoanProductSettingsStepComponent implements OnInit {
standingInstructionChargeGLAccount: [''],
standingInstructionDebitChargeSkipOption: [''],
openingBalanceMultiplierForRemittanceCharges: [''],
accountMoveToInoperativeEnabled: ['NO'],
generateGlDiffEntryEnabled: ['NO'],
subTypeWiseGlBalanceGenerationEnabled: ['NO'],
validateBeforeDeletingDebitEntryEnabled: ['NO'],
rcFileConceptEnabled: ['NO'],
accountCloseZeroBalanceInOpenDayProcess: ['NO'],
accountCloseZeroBalanceAtSubledgerTranEntry: ['NO'],
limitEnhancementAgainstClearingScreenDisplayEnabled: ['NO'],
advanceInterestParameterInLedgerEnabled: ['NO'],
vehicleDetailInMasterEnabled: ['NO'],
deletedChequeIssueEnabled: ['NO'],
dpReduceInDocumentRenewalDateEnabled: ['NO'],
dpReduceInRenewalDateEnabled: ['NO'],
autoCloseAtDayEndEnabled: ['NO'],
loanRateFromMasterEnabled: ['NO'],
chequeShortName: [''],
statementPrintEnabled: ['YES'],
neslCode: [''],
// Interest Related Parameters
drawingPowerAndLimitGenerationProcedure: ['NORMAL D.P. ENTRY IS CONSIDER'],
@@ -385,7 +443,7 @@ export class LoanProductSettingsStepComponent implements OnInit {
penaltyInterestDateDifferentThanRegularInterest: ['YES'],
fixedDepositOverdraftCertificateSortOrder: ['ORDER ON INTEREST RATE'],
useDocumentSubmissionDateForInterest: ['NO'],
loanManagementSystemRenewalPenaltyActive: [''],
loanManagementSystemRenewalPenaltyActive: ['NO'],
minimumNormalInterestAmount: [''],
minimumInterestCalculationMethod: [''],
minimumAgainstClearingInterestAmount: [''],
@@ -409,6 +467,18 @@ export class LoanProductSettingsStepComponent implements OnInit {
nonPerformingAssetPenalInterestRate: [''],
fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess: ['YES'],
addPenalInterestRate: [''],
atyp14SimpleInterestEnabled: ['NO'],
fixedDepositOverdraftMarginType: [''],
checkMasterDetailEditedPassingPendingEnabled: ['NO'],
creditDebitInterestCalculationSeparateEnabled: ['NO'],
onTimeInterestProvisionEnabled: ['NO'],
checkAgainstClearingEnabled: ['NO'],
gstOnPenaltyEnabled: ['NO'],
penaltyInterestGraceDays: [''],
overdrawnInterestRate: [''],
calculateDailyLimitEnhancementTodEnabled: ['NO'],
fixedDepositOverdraftMultipleInterestRateEnabled: ['NO'],
graceDaysAfterOverdueForMoratorium: [''],
// Credit Bureau Settings (CIBIL)
cibilAccountType: [''],
@@ -426,7 +496,60 @@ export class LoanProductSettingsStepComponent implements OnInit {
isFloatingRate: [false],
foreclosureChargesApplicable: ['NO'],
keyFactStatementEnabled: [true],
annualPercentageRateCalculationEnabled: [true]
annualPercentageRateCalculationEnabled: [true],
// Channel & Transaction Limits
isMobileAppEnabled: ['NO'],
isAtmEnabled: ['NO'],
atmYearlyChargeEnabled: ['NO'],
atmMinimumBalance: [''],
emailActiveEnabled: ['NO'],
isImpsEnabled: ['NO'],
isUpiEnabled: ['NO'],
isPosEnabled: ['NO'],
isBbpsEnabled: ['NO'],
interBankLimitPerDay: [''],
interBankLimitPerTransaction: [''],
neftRtgsLimitPerDay: [''],
neftRtgsLimitPerTransaction: [''],
neftCutoffLimit: [''],
rtgsCutoffLimit: [''],
neftRtgsMinimumBalance: [''],
isChargeRequiredNeftRtgsCbs: [''],
isChargeRequiredNeftRtgsMobile: [''],
impsLimitPerDay: [''],
impsLimitPerTransaction: [''],
impsLimitPerMonth: [''],
impsMinLimit: [''],
impsMonthlyTransactionCount: [''],
// POS Facility
maxPerTransactionLimitAtm: [''],
maxDayTransactionLimitAtm: [''],
maxPerTransactionLimitPos: [''],
maxDayTransactionLimitPos: [''],
maxPerTransactionLimitEcom: [''],
maxDayTransactionLimitEcom: [''],
maxPerTransactionLimitUpi: [''],
maxDayTransactionLimitUpi: [''],
// ATM CSV File Related Other Details
atmCsvProductCode: [''],
atmCsvTypeCode: [''],
// Auto Interest Post Parameters
autoInterestPostType: [''],
autoInterestPostWhen: [''],
autoInterestPostFixDay: [''],
autoLastInterestDate: [''],
// Foreclosure / Early Account Closure
calculateEarlyAccountCloseCharge: ['NO'],
earlyAccountCloseCharge: [''],
earlyAccountClosePeriodInMonths: [''],
// Gold Loan Specific
goldLoanPacketNumberWithYearEnabled: ['NO']
});
}
@@ -829,10 +952,20 @@ export class LoanProductSettingsStepComponent implements OnInit {
}
get loanProductSettings() {
const productSettings = this.loanProductSettingsForm.value;
const productSettings = { ...this.loanProductSettingsForm.value };
if (productSettings['delinquencyBucketId'] === '') {
productSettings['delinquencyBucketId'] = null;
}
// `value` omits disabled controls, which would leave a legacy field blank on the preview step
// even though the officer filled it in. Only the custom fields are added back, never the
// standard Fineract ones -- those are disabled deliberately and must stay out of the payload.
// Carrying the custom ones is free: buildPayload() strips every one of them before submitting.
for (const field of CUSTOM_UI_ONLY_FIELDS) {
const control = this.loanProductSettingsForm.get(field);
if (control?.disabled) {
productSettings[field] = control.value;
}
}
return productSettings;
}
@@ -17,6 +17,69 @@
matTooltip="{{ 'tooltips.Fields are used to define the minimum, default, and maximum principal' | translate }}"
></i>
</div>
<div class="lpt-subsection">
<div class="lpt-grid lpt-grid-4">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Minimum' | translate }}</mat-label>
<input type="number" matInput formControlName="minPrincipal" [min]="0" />
<mat-error *ngIf="loanProductTermsForm.get('minPrincipal')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>0</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Default' | translate }}</mat-label>
<input type="number" matInput formControlName="principal" required [min]="0" />
<mat-error *ngIf="loanProductTermsForm.get('principal')?.hasError('required')">
{{ 'labels.inputs.Principal' | translate }}
<span class="text-danger">{{ 'labels.commons.is required' | translate }}</span>
</mat-error>
<mat-error *ngIf="loanProductTermsForm.get('principal')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>0</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Maximum' | translate }}</mat-label>
<input type="number" matInput formControlName="maxPrincipal" [min]="0" />
<mat-error *ngIf="loanProductTermsForm.get('maxPrincipal')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>0</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpt-subsection">
<h4 class="lpt-subsection-title">
{{ 'labels.inputs.Number of Repayments' | translate }}
</h4>
<div class="lpt-grid lpt-grid-4">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Minimum' | translate }}</mat-label>
<input type="number" matInput formControlName="minNumberOfRepayments" [min]="1" />
<mat-error *ngIf="loanProductTermsForm.get('minNumberOfRepayments')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>1</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Default' | translate }}</mat-label>
<input type="number" matInput formControlName="numberOfRepayments" required [min]="1" />
<mat-error *ngIf="loanProductTermsForm.get('numberOfRepayments')?.hasError('required')">
{{ 'labels.inputs.Number of Repayments' | translate }}
<span class="text-danger">{{ 'labels.commons.is required' | translate }}</span>
</mat-error>
<mat-error *ngIf="loanProductTermsForm.get('numberOfRepayments')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>1</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Maximum' | translate }}</mat-label>
<input type="number" matInput formControlName="maxNumberOfRepayments" [min]="1" />
<mat-error *ngIf="loanProductTermsForm.get('maxNumberOfRepayments')?.hasError('min')">
{{ 'labels.commons.Minimum Value must be' | translate }} <strong>1</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpt-grid">
<div class="lpt-checkbox-row lpt-full-width">
<mat-checkbox labelPosition="before" formControlName="allowApprovedDisbursedAmountsOverApplied">
@@ -264,90 +327,56 @@
}
</div>
<!-- Interest Rate Slabs Section -->
<!-- Interest Rate Detail Section (legacy Page 1 table) -->
<div class="lpt-section">
<div class="lpt-section-header">
<i class="fas fa-table lpt-section-icon"></i>
<h3>Interest Rate Slabs</h3>
<h3>Interest Rate Detail</h3>
</div>
<div class="lpt-slab-c">
<!-- Particular Name -->
<div class="lpt-slab-c-particular">
<label class="lpt-slab-c-particular-label">Particular</label>
<input
type="text"
class="lpt-slab-c-name-input"
[formControl]="particularLabel"
placeholder="e.g. Consumer Loan"
/>
</div>
<!-- 2-column slab grid (same as before, General + Special inside each card) -->
<div class="lpt-slab-c-grid">
@for (genGroup of slabGroups; track $index) {
<div class="lpt-slab-c-item">
<span class="lpt-slab-c-range">{{ interestSlabDefinitions[$index].label }}</span>
<div class="lpt-slab-c-rate-pair">
<!-- General -->
<div class="lpt-slab-c-rate-field" [formGroup]="genGroup">
<span class="lpt-slab-c-rate-label">Gen</span>
<div class="lpt-slab-c-input-wrap">
<input
type="number"
class="lpt-slab-c-input"
formControlName="interestRate"
placeholder="0.00"
min="0"
max="100"
step="0.01"
/>
<span class="lpt-slab-c-suffix">%</span>
</div>
</div>
<!-- Special -->
<div class="lpt-slab-c-rate-field" [formGroup]="specialSlabGroups[$index]">
<span class="lpt-slab-c-rate-label lpt-slab-c-rate-label--special">Spl</span>
<div class="lpt-slab-c-input-wrap">
<input
type="number"
class="lpt-slab-c-input lpt-slab-c-input--special"
formControlName="interestRate"
placeholder="0.00"
min="0"
max="100"
step="0.01"
/>
<span class="lpt-slab-c-suffix">%</span>
</div>
</div>
</div>
</div>
}
</div>
<!-- Fixed Interest Rate row -->
<div class="lpt-slab-c-fixed" [formGroup]="fixedRateGroup">
<label class="lpt-slab-c-fixed-toggle">
<input type="checkbox" formControlName="hasFixed" class="lpt-slab-c-checkbox" />
<i class="fas fa-lock"></i>
<span>Fixed Interest Rate (%)</span>
</label>
@if (fixedRateGroup.get('hasFixed').value) {
<div class="lpt-slab-c-input-wrap">
<input
type="number"
class="lpt-slab-c-input lpt-slab-c-input--fixed"
formControlName="fixedAmount"
placeholder="0.00"
min="0"
max="100"
step="0.01"
/>
<span class="lpt-slab-c-suffix">%</span>
</div>
}
</div>
<div class="lpt-ird-table-wrap">
<table class="lpt-ird-table">
<thead>
<tr>
<th rowspan="2">Interest Type</th>
<th rowspan="2">Calculation</th>
<th colspan="2">Normal Category</th>
<th colspan="2">Special Category</th>
<th rowspan="2">Period</th>
</tr>
<tr>
<th>Mini. Rate</th>
<th>Maxi. Rate</th>
<th>Mini. Rate</th>
<th>Maxi. Rate</th>
</tr>
</thead>
<tbody>
@for (row of interestRateDetailGroups; track $index) {
<tr [formGroup]="row">
<td class="lpt-ird-type">{{ interestRateDetailTypes[$index] }}</td>
<td>
<select formControlName="calculation" class="lpt-ird-select">
<option [ngValue]="true">YES</option>
<option [ngValue]="false">NO</option>
</select>
</td>
<td><input type="number" formControlName="normalMinRate" class="lpt-ird-input" min="0" max="100" /></td>
<td><input type="number" formControlName="normalMaxRate" class="lpt-ird-input" min="0" max="100" /></td>
<td><input type="number" formControlName="specialMinRate" class="lpt-ird-input" min="0" max="100" /></td>
<td><input type="number" formControlName="specialMaxRate" class="lpt-ird-input" min="0" max="100" /></td>
<td>
<select formControlName="period" class="lpt-ird-select">
<option value=""></option>
@for (period of interestRateDetailPeriods; track period) {
<option [value]="period">{{ period }}</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@@ -199,175 +199,42 @@ $shadow-hover: 0 4px 12px rgb(37 99 235 / 10%);
padding: 8px 4px;
}
// ─── Interest Rate Slabs — Option C (2-column grid) ──────────────────────────
.lpt-slab-c {
// ─── Interest Rate Detail table (legacy Page 1 grid) ─────────────────────────
.lpt-ird-table-wrap {
padding: 16px 24px 20px;
display: flex;
flex-direction: column;
gap: 0;
overflow-x: auto;
}
// Particular row
.lpt-slab-c-particular {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 14px;
border-bottom: 1px solid $border;
margin-bottom: 12px;
flex-wrap: wrap;
}
.lpt-slab-c-particular-label {
font-size: 12px;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.lpt-slab-c-name-input {
flex: 1;
max-width: 320px;
padding: 7px 12px;
border: 1px solid $border;
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
color: $text-primary;
background: $surface;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
&::placeholder {
color: #b0b8c4;
font-weight: 400;
}
&:focus {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(37 99 235 / 10%);
}
}
// General / Special tab toggle
.lpt-slab-tabs {
display: flex;
gap: 6px;
margin-left: auto;
}
.lpt-slab-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
border: 1.5px solid $border;
border-radius: 20px;
background: $surface;
.lpt-ird-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
font-weight: 600;
color: $text-secondary;
cursor: pointer;
transition: all 0.15s;
i {
th,
td {
border: 1px solid $border;
padding: 6px 8px;
text-align: center;
}
thead th {
background: $surface-alt;
font-weight: 700;
color: $text-secondary;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 11px;
}
&:hover {
border-color: $primary;
color: $primary;
}
&--active {
background: $primary;
border-color: $primary;
color: $surface;
box-shadow: 0 2px 8px rgb(37 99 235 / 25%);
}
}
// 2-column slab grid
.lpt-slab-c-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
@media (width <= 540px) {
grid-template-columns: 1fr;
}
}
.lpt-slab-c-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border: 1px solid $border;
border-radius: 8px;
background: $surface;
transition:
border-color 0.15s,
box-shadow 0.15s;
&:focus-within {
border-color: $primary;
box-shadow: 0 0 0 3px rgb(37 99 235 / 8%);
}
}
// General + Special pair inside each card
.lpt-slab-c-rate-pair {
display: flex;
gap: 8px;
align-items: center;
}
.lpt-slab-c-rate-field {
display: flex;
align-items: center;
gap: 4px;
}
.lpt-slab-c-rate-label {
font-size: 10px;
font-weight: 700;
color: $primary;
background: #eff6ff;
padding: 2px 5px;
border-radius: 4px;
.lpt-ird-type {
text-align: left !important;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
&--special {
color: #92400e;
background: #fef9c3;
}
}
.lpt-slab-c-range {
font-size: 12.5px;
font-weight: 500;
color: $text-label;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Input + suffix wrapper
.lpt-slab-c-input-wrap {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.lpt-slab-c-input {
.lpt-ird-input {
width: 70px;
padding: 4px 6px;
border: 1px solid $border;
@@ -379,79 +246,26 @@ $shadow-hover: 0 4px 12px rgb(37 99 235 / 10%);
background: $surface-alt;
outline: none;
transition: border-color 0.15s;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
&:focus {
border-color: $primary;
background: $primary-light;
}
&--fixed {
width: 90px;
}
}
.lpt-slab-c-suffix {
font-size: 12px;
font-weight: 700;
color: $text-secondary;
user-select: none;
&--special {
color: #92400e;
}
}
.lpt-slab-c-input--special {
&:focus {
border-color: #f59e0b;
background: #fef9c3;
}
}
.lpt-slab-c-error {
font-size: 10px;
color: #ef4444;
}
// Fixed Interest Rate row (below grid)
.lpt-slab-c-fixed {
display: flex;
align-items: center;
gap: 20px;
margin: 12px -24px -20px;
padding: 12px 16px;
border-top: 2px dashed $primary-border;
background: $primary-light;
border-radius: 0 0 $radius-card $radius-card;
padding-left: 24px;
padding-right: 24px;
}
.lpt-slab-c-fixed-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.lpt-ird-select {
width: 100%;
min-width: 90px;
padding: 4px 6px;
border: 1px solid $border;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: $primary;
user-select: none;
color: $text-primary;
background: $surface;
outline: none;
i {
font-size: 12px;
opacity: 0.7;
&:focus {
border-color: $primary;
}
}
.lpt-slab-c-checkbox {
accent-color: $primary;
width: 15px;
height: 15px;
cursor: pointer;
}
@@ -17,6 +17,7 @@ import {
FormControl,
ReactiveFormsModule
} from '@angular/forms';
import { patchCustomFields } from '../../loan-product-custom-fields';
import { ProcessingStrategyService } from '../../services/processing-strategy.service';
import { TranslateService } from '@ngx-translate/core';
import { MatTooltip } from '@angular/material/tooltip';
@@ -57,24 +58,23 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
repaymentFrequencyTypeData: any;
repaymentStartDateTypeOptions: any;
readonly interestSlabDefinitions = [
{ label: 'Upto 10,000', maxAmount: 10000 },
{ label: 'Upto 25,000', maxAmount: 25000 },
{ label: 'Upto 1,00,000', maxAmount: 100000 },
{ label: 'Upto 2,00,000', maxAmount: 200000 },
{ label: 'Upto 5,00,000', maxAmount: 500000 },
{ label: 'Upto 10,00,000', maxAmount: 1000000 },
{ label: 'Upto 15,00,000', maxAmount: 1500000 },
{ label: 'Upto 20,00,000', maxAmount: 2000000 },
{ label: 'Upto 25,00,000', maxAmount: 2500000 },
{ label: 'More than 25,00,000', maxAmount: null }
// Legacy "Interest Rate Detail" table — 4 interest types x Normal/Special category x Min/Max Rate x Period
readonly interestRateDetailTypes = [
'Normal Debit',
'Normal Credit',
'Against Clearing',
'Penalty'
];
interestRateSlabsArray: FormArray;
specialSlabsArray: FormArray;
fixedRateGroup: FormGroup;
particularLabel = new FormControl('');
activeSlabTab: 'general' | 'special' = 'general';
readonly interestRateDetailPeriods = [
'DAILY',
'MONTHLY',
'QUARTERLY',
'HALF YEARLY',
'YEARLY'
];
interestRateDetailArray: FormArray;
constructor() {
this.createLoanProductTermsForm();
@@ -92,6 +92,12 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
];
this.loanProductTermsForm.patchValue({
minPrincipal: this.loanProductsTemplate.minPrincipal,
principal: this.loanProductsTemplate.principal,
maxPrincipal: this.loanProductsTemplate.maxPrincipal,
minNumberOfRepayments: this.loanProductsTemplate.minNumberOfRepayments,
numberOfRepayments: this.loanProductsTemplate.numberOfRepayments,
maxNumberOfRepayments: this.loanProductsTemplate.maxNumberOfRepayments,
isLinkedToFloatingInterestRates: this.loanProductsTemplate.isLinkedToFloatingInterestRates,
minInterestRatePerPeriod: this.loanProductsTemplate.minInterestRatePerPeriod,
interestRatePerPeriod: this.loanProductsTemplate.interestRatePerPeriod,
@@ -121,6 +127,11 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
});
}
// Repopulate the legacy "Account Type Creation" fields saved against this product.
// Last, so a value actually stored wins over any default set above.
patchCustomFields(this.loanProductTermsForm, this.loanProductsTemplate);
this.patchInterestRateDetail();
this.zeroInterest.patchValue(
this.loanProductsTemplate.minInterestRatePerPeriod === 0 &&
this.loanProductsTemplate.interestRatePerPeriod === 0 &&
@@ -134,36 +145,63 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
}
createLoanProductTermsForm() {
this.interestRateSlabsArray = new FormArray(
this.interestSlabDefinitions.map(
this.interestRateDetailArray = new FormArray(
this.interestRateDetailTypes.map(
() =>
new FormGroup({
interestRate: new FormControl(null, [
calculation: new FormControl(false),
normalMinRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
])
]),
normalMaxRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
]),
specialMinRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
]),
specialMaxRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
]),
period: new FormControl('')
})
)
);
this.specialSlabsArray = new FormArray(
this.interestSlabDefinitions.map(
() =>
new FormGroup({
interestRate: new FormControl(null, [
Validators.min(0),
Validators.max(100)
])
})
)
);
this.fixedRateGroup = new FormGroup({
hasFixed: new FormControl(false),
fixedAmount: new FormControl({ value: null, disabled: true }, [Validators.min(0)])
});
this.loanProductTermsForm = this.formBuilder.group({
minPrincipal: [
'',
[Validators.min(0)]
],
principal: [
'',
[
Validators.required,
Validators.min(0)
]
],
maxPrincipal: [
'',
[Validators.min(0)]
],
minNumberOfRepayments: [
'',
[Validators.min(1)]
],
numberOfRepayments: [
'',
[
Validators.required,
Validators.min(1)
]
],
maxNumberOfRepayments: [
'',
[Validators.min(1)]
],
isLinkedToFloatingInterestRates: [false],
allowApprovedDisbursedAmountsOverApplied: [false],
overAppliedCalculationType: [{ value: null, disabled: true }],
@@ -287,15 +325,6 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
}
});
this.fixedRateGroup.get('hasFixed').valueChanges.subscribe((hasFixed: boolean) => {
if (hasFixed) {
this.fixedRateGroup.get('fixedAmount').enable();
} else {
this.fixedRateGroup.get('fixedAmount').disable();
this.fixedRateGroup.get('fixedAmount').setValue(null);
}
});
this.zeroInterest.valueChanges.subscribe((zeroInterest) => {
if (zeroInterest) {
this.loanProductTermsForm.get('minInterestRatePerPeriod').patchValue(0);
@@ -322,16 +351,34 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
});
}
get slabGroups(): FormGroup[] {
return this.interestRateSlabsArray.controls as FormGroup[];
get interestRateDetailGroups(): FormGroup[] {
return this.interestRateDetailArray.controls as FormGroup[];
}
get activeSlabGroups(): FormGroup[] {
return this.interestRateSlabsArray.controls as FormGroup[];
}
get specialSlabGroups(): FormGroup[] {
return this.specialSlabsArray.controls as FormGroup[];
/**
* Fills the legacy Interest Rate Detail grid from a saved product. Rows are matched on
* `interestType` rather than by position, so a payload that comes back in a different order cannot
* put one interest type's rates onto another. A type with no saved row is left at its defaults.
*/
private patchInterestRateDetail(): void {
const saved: any[] = this.loanProductsTemplate?.interestRateDetail;
if (!Array.isArray(saved) || saved.length === 0) {
return;
}
this.interestRateDetailTypes.forEach((interestType, index) => {
const row = saved.find((r) => r?.interestType === interestType);
if (!row) {
return;
}
this.interestRateDetailArray.at(index).patchValue({
calculation: row.calculation ?? false,
normalMinRate: row.normalMinRate,
normalMaxRate: row.normalMaxRate,
specialMinRate: row.specialMinRate,
specialMaxRate: row.specialMaxRate,
period: row.period ?? ''
});
});
}
get loanProductTerms() {
@@ -349,15 +396,10 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
minInterestRatePerPeriod: normalizeDecimal(formValue.minInterestRatePerPeriod),
interestRatePerPeriod: normalizeDecimal(formValue.interestRatePerPeriod),
maxInterestRatePerPeriod: normalizeDecimal(formValue.maxInterestRatePerPeriod),
interestRateSlabs: this.interestRateSlabsArray.getRawValue().map((slab, i) => ({
maxAmount: this.interestSlabDefinitions[i].maxAmount,
label: this.interestSlabDefinitions[i].label,
interestRate: slab.interestRate
})),
fixedRate: (() => {
const fr = this.fixedRateGroup.getRawValue();
return fr.hasFixed ? fr.fixedAmount : null;
})()
interestRateDetail: this.interestRateDetailArray.getRawValue().map((row, i) => ({
interestType: this.interestRateDetailTypes[i],
...row
}))
};
}
@@ -9,6 +9,10 @@
import { Injectable, inject } from '@angular/core';
import { SettingsService } from 'app/settings/settings.service';
import { CUSTOM_DATE_FIELDS, CUSTOM_YES_NO_STRING_FIELDS } from './loan-product-custom-fields';
export { CUSTOM_UI_ONLY_FIELDS } from './loan-product-custom-fields';
@Injectable({
providedIn: 'root'
})
@@ -35,6 +39,42 @@ export class LoanProducts {
return loanProductTemplate;
}
/**
* The backend returns the legacy "Account Type Creation" fields nested under `cbsExtensions`, while
* every step form reads and writes them as flat properties on the product. Lifting them here keeps
* that difference in one place rather than teaching each step about the nesting.
*
* Two shapes also have to be converted back to what the form controls expect:
*
* - the yes/no dropdowns hold the strings 'YES'/'NO', but the column is BOOLEAN, so the API returns
* true/false -- patched as-is the control matches no option and shows blank;
* - a date arrives as Fineract's [year, month, day] array rather than a Date.
*
* Mutates and returns the product so it can be dropped into an existing resolver chain.
*/
public flattenCbsExtensions(loanProduct: any): any {
if (!loanProduct?.cbsExtensions) {
return loanProduct;
}
Object.assign(loanProduct, loanProduct.cbsExtensions);
for (const field of CUSTOM_YES_NO_STRING_FIELDS) {
const value = loanProduct[field];
if (typeof value === 'boolean') {
loanProduct[field] = value ? 'YES' : 'NO';
}
}
for (const field of CUSTOM_DATE_FIELDS) {
const value = loanProduct[field];
if (Array.isArray(value) && value.length >= 3) {
loanProduct[field] = new Date(value[0], value[1] - 1, value[2]);
}
}
return loanProduct;
}
public buildPayload(loanProductData: any, itemsByDefault: any) {
const dateFormat: string = this.settingsService.dateFormat;
const locale: string = this.settingsService.language.code;
@@ -49,6 +89,12 @@ export class LoanProducts {
delete loanProduct.allowAttributeConfiguration;
delete loanProduct.advancedAccountingRules;
// The legacy "Account Type Creation" fields used to be stripped here, because Fineract's loan
// product validator rejected any parameter it did not recognise. They are now persisted by
// LoanProductCbsExtService in a satellite table keyed on the product, so they are sent as-is:
//
// CUSTOM_UI_ONLY_FIELDS.forEach((field) => delete loanProduct[field]);
// In Fineract, the POST and PUT endpoints for /v1/loanproducts have a typo in the field
// allowPartialPeriodInterestCalculation. Until that is fixed, we need to replace the field name in the payload.
loanProduct.allowPartialPeriodInterestCalculation = loanProduct.allowPartialPeriodInterestCalculation;
+17 -24
View File
@@ -47,11 +47,11 @@ import { ViewTaxComponentComponent } from './manage-tax-components/view-tax-comp
import { CreateTaxComponentComponent } from './manage-tax-components/create-tax-component/create-tax-component.component';
import { EditTaxComponentComponent } from './manage-tax-components/edit-tax-component/edit-tax-component.component';
import { ViewTaxGroupComponent } from './manage-tax-groups/view-tax-group/view-tax-group.component';
import { ShareProductsDividendsComponent } from './share-products/dividends-share-product/dividends.components';
import { CreateRecurringDepositProductComponent } from './recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component';
import { CreateDividendComponent } from './share-products/create-dividend/create-dividend.component';
import { ViewFixedDepositProductComponent } from './fixed-deposit-products/view-fixed-deposit-product/view-fixed-deposit-product.component';
import { ViewDividendComponent } from './share-products/view-dividend/view-dividend.component';
import { AllProductsDividendsComponent } from './share-products/all-products-dividends/all-products-dividends.component';
import { DeclareDividendComponent } from './share-products/all-products-dividends/declare-dividend/declare-dividend.component';
import { CreateTaxGroupComponent } from './manage-tax-groups/create-tax-group/create-tax-group.component';
import { EditTaxGroupComponent } from './manage-tax-groups/edit-tax-group/edit-tax-group.component';
import { CreateProductMixComponent } from './products-mix/create-product-mix/create-product-mix.component';
@@ -370,23 +370,6 @@ const routes: Routes = [
shareProductsTemplate: ShareProductsTemplateResolver
}
},
// Must stay ahead of ':productId', which would otherwise swallow 'dividends' as a
// product id and route to a product that does not exist.
{
path: 'dividends',
data: { title: 'Dividends', breadcrumb: 'Dividends' },
children: [
{
path: '',
component: AllProductsDividendsComponent
},
{
path: 'declare',
component: DeclareDividendComponent,
data: { title: 'Declare Dividend', breadcrumb: 'Declare', routeParamBreadcrumb: false }
}
]
},
{
path: ':productId',
data: { title: 'View Share Product', breadcrumb: 'productId', routeParamBreadcrumb: 'productId' },
@@ -434,15 +417,25 @@ const routes: Routes = [
shareProductAndTemplate: ShareProductAndTemplateResolver
}
},
// A dividend is now declared for the whole society at once, at
// share-products/dividends, because the board resolves one rate for the year rather
// than one per product. What remains here is the per-product declaration view: the
// member-by-member allocation and the approve action still belong to the product,
// since share capital sits in a separate GL per product.
{
path: 'dividends',
data: { title: 'Share Products Dividends', breadcrumb: 'Dividends', routeParamBreadcrumb: false },
children: [
{
path: '',
component: ShareProductsDividendsComponent,
resolve: {
dividends: DividendsResolver
}
},
{
path: 'create',
component: CreateDividendComponent,
data: { title: 'Create Dividend', breadcrumb: 'Create', routeParamBreadcrumb: false },
resolve: {
shareProduct: ShareProductResolver
}
},
{
path: ':dividendId',
component: ViewDividendComponent,
+3 -104
View File
@@ -190,58 +190,6 @@ export class ProductsService {
return this.http.get(`/shareproduct/${shareProductId}/dividend`);
}
/**
* Every dividend declaration, across all share products.
*
* The board resolves one rate for the society, not one per share product, so the register that
* matters is the society-wide one. Each product still carries its own declaration row underneath,
* because share capital sits in a separate GL per product.
* @returns {Observable<any>} Declarations, newest period first.
*/
getAllProductsDividends(): Observable<any> {
return this.http.get('/shareproducts/dividends');
}
/**
* What a rate would cost, broken down per share product, before anything is declared.
*
* A rate is a promise about each member's own paid-up capital, so what it costs depends on how
* much capital each product's members held and for how long. Showing that split first is what
* makes one cross-product decision safe to take.
* @param fromDate Period start, yyyy-MM-dd.
* @param toDate Period end, yyyy-MM-dd.
* @param ratePercentage Rate per annum.
* @returns {Observable<any>} `{ totalAmount, products: [{ productName, amount, eligible, reason }] }`.
*/
previewAllProductsDividend(
fromDate: string,
toDate: string,
ratePercentage: number,
dividendBasis: string
): Observable<any> {
const params: any = { fromDate, toDate };
if (ratePercentage != null) {
params.ratePercentage = ratePercentage;
}
if (dividendBasis) {
params.dividendBasis = dividendBasis;
}
return this.http.get('/shareproducts/dividends/preview', { params });
}
/**
* Declares one dividend across every eligible share product at once.
*
* Products with no eligible members, or one already declared for this period, are skipped and
* reported rather than failing the run — one ineligible product is not a reason to leave the
* rest undeclared.
* @param dividendData `{ dividendPeriodStartDate, dividendPeriodEndDate, ratePercentage }`.
* @returns {Observable<any>} `{ declarationsCreated, created, skipped }`.
*/
createAllProductsDividend(dividendData: any): Observable<any> {
return this.http.post('/shareproducts/dividends', dividendData);
}
/**
* Create Dividend.
* @param shareProductId Share Product Id.
@@ -252,64 +200,15 @@ export class ProductsService {
return this.http.post(`/shareproduct/${shareProductId}/dividend`, dividendData);
}
/**
* Weighted paid-up share capital for a period, and what a given rate per annum on it costs.
*
* This is what makes a declared "9%" mean 9% on each member's own capital — the promise an AGM
* resolution actually makes — rather than 9% of the society's profit split across however many
* members happen to hold shares that year.
* @param shareProductId Share Product Id.
* @param fromDate Period start, yyyy-MM-dd.
* @param toDate Period end, yyyy-MM-dd.
* @param ratePercentage Rate per annum; omit to get the weighted capital alone.
* @returns {Observable<any>} `{ weightedCapital, requiredAmount, eligibleAccounts,
* minimumActivePeriodDays }`.
*/
getDividendCapitalPreview(
shareProductId: string,
fromDate: string,
toDate: string,
ratePercentage?: number
): Observable<any> {
let httpParams = new HttpParams().set('fromDate', fromDate).set('toDate', toDate);
if (ratePercentage != null) {
httpParams = httpParams.set('ratePercentage', String(ratePercentage));
}
return this.http.get(`/shareproduct/${shareProductId}/dividend-capital-preview`, { params: httpParams });
}
/**
* Every member's share of one dividend declaration.
*
* Unpaginated on purpose. The screen this feeds totals the rows it receives and shows that as the
* declaration's amount, so a page-sized slice made the total wrong — a 22-member declaration of
* ₹22,190 read as ₹0.00 because the first ten members all happened to fall below the minimum. An
* approval decision cannot be taken against a figure that reflects only part of the register.
*/
getDividendData(shareProductId: any, dividendId: any): Observable<any> {
const httpParams = new HttpParams()
.set('dateFormat', this.settingsService.dateFormat)
.set('locale', this.settingsService.language.code);
.set('limit', '10')
.set('locale', this.settingsService.language.code)
.set('offset', '0');
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}`, { params: httpParams });
}
/**
* What a declaration did NOT pay out to members: paise shaved off by rounding each entitlement
* down to a whole rupee, plus entitlements dropped for falling under the minimum worth drawing a
* Demand Draft for. Both are swept to Bank Profit at declaration time.
* @returns {Observable<any>} `{ declaredAmount, allocatedAmount, residueAmount, memberCount,
* belowMinimumCount, belowMinimumAmount, sweptToProfitDate, glTransactionId }`, or null for
* declarations made before this was tracked.
*/
getDividendRoundingResidue(shareProductId: any, dividendId: any): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}/rounding-residue`);
}
/** Every declaration's rounding residue for one product, newest first, with a running total. */
getDividendRoundingResidueHistory(shareProductId: any): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend/rounding-residue`);
}
approveDividend(shareProductId: any, dividendId: any, data: any): Observable<any> {
const httpParams = new HttpParams().set('command', 'approve');
return this.http.put(`/shareproduct/${shareProductId}/dividend/${dividendId}`, data, { params: httpParams });
@@ -1,182 +0,0 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div class="pl-header-text">
<h2>Dividends</h2>
<p>One rate per financial year, applied across every share product.</p>
</div>
</div>
<div class="pl-header-right">
<div class="pl-count-chip" matTooltip="Declarations on record">
<fa-icon icon="layer-group"></fa-icon>
<strong>{{ dividends.length }}</strong>
<span>{{ dividends.length === 1 ? 'declaration' : 'declarations' }}</span>
</div>
<button
class="pl-create-btn"
type="button"
[routerLink]="['declare']"
*mifosxHasPermission="'CREATE_SHAREDIVIDEND'"
>
<fa-icon icon="plus"></fa-icon>
<span>Declare Dividend</span>
</button>
</div>
</div>
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="rupee-sign"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalDeclared | number: '1.2-2' }}</div>
<div class="pl-kpi-l">
Total Declared
@if (pendingCount > 0) {
<span class="apd-pending">· {{ pendingCount }} awaiting approval</span>
}
</div>
</div>
</div>
<div class="pl-kpi pl-kpi--teal">
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ memberCount | number }}</div>
<div class="pl-kpi-l">Members Receiving</div>
</div>
</div>
<div class="pl-kpi pl-kpi--purple">
<div class="pl-kpi-ic"><fa-icon icon="coins"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ productCount | number }}</div>
<div class="pl-kpi-l">Share Products Covered</div>
</div>
</div>
</div>
@if (financialYears.length > 1) {
<div class="apd-years">
<span class="apd-years-label">Financial Year</span>
@for (year of financialYears; track year) {
<button
type="button"
class="apd-year-chip"
[class.apd-year-chip--on]="selectedYear === year"
(click)="selectYear(year)"
>
{{ year }}
</button>
}
</div>
}
<div class="pl-card">
<div class="pl-toolbar">
<div class="pl-search">
<span class="pl-search-ic"><fa-icon icon="search"></fa-icon></span>
<input type="text" placeholder="Search by product or year…" (keyup)="applyFilter($any($event.target).value)" />
</div>
</div>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (loadFailed) {
<div class="apd-empty apd-empty--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<p>Could not load dividends. This is a connection problem, not an empty register.</p>
<button type="button" class="pl-secondary-btn" (click)="loadDividends()">Retry</button>
</div>
} @else if (!loading && dataSource.data.length === 0) {
<div class="apd-empty">
<fa-icon icon="hand-holding-usd"></fa-icon>
<p>No dividend has been declared yet.</p>
</div>
}
<!-- @if, not [hidden]: the wrapper sets overflow-x, which gives it a display value that wins
over the hidden attribute, so [hidden] left the table in the layout without showing it. -->
@if (!loadFailed && dataSource.data.length > 0) {
<div class="pl-table-wrap">
<table mat-table class="pl-table" [dataSource]="dataSource" matSort>
<ng-container matColumnDef="financialYear">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Financial Year</th>
<td mat-cell *matCellDef="let row">
<span class="pl-tag">{{ row.financialYear }}</span>
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Share Product</th>
<td mat-cell *matCellDef="let row">
<div class="pl-product-cell">
<div class="pl-product-icon"><fa-icon icon="coins"></fa-icon></div>
<div class="pl-product-meta">
<div class="pl-product-name">{{ row.productName }}</div>
<div class="pl-product-sub">Declaration #{{ row.dividendId }}</div>
</div>
</div>
</td>
</ng-container>
<ng-container matColumnDef="period">
<th mat-header-cell *matHeaderCellDef>Period</th>
<td mat-cell *matCellDef="let row">
{{ row.periodStartDate | dateFormat }} – {{ row.periodEndDate | dateFormat }}
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Amount</th>
<td mat-cell *matCellDef="let row">
<strong>{{ row.amount | number: '1.2-2' }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="memberCount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Members</th>
<td mat-cell *matCellDef="let row">{{ row.memberCount | number }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="pl-status-pill" [class.apd-status--pending]="!row.approved">
{{ row.approved ? 'Approved' : 'Awaiting Approval' }}
</span>
</td>
</ng-container>
<!-- The approve action lives on the declaration's own page, alongside the member-by-member
allocation. Naming it here rather than relying on the row click makes it findable —
an officer should not have to guess that a row opens somewhere they can approve. -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row">
<button type="button" class="apd-action" (click)="viewDividend(row); $event.stopPropagation()">
{{ row.approved ? 'View' : 'Review & Approve' }}
<fa-icon icon="chevron-right"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row class="pl-row" *matRowDef="let row; columns: displayedColumns" (click)="viewDividend(row)"></tr>
</table>
</div>
}
<!-- pageSize is required, not optional: without it the paginator starts at zero and pages every
row out, leaving a table that looks empty while the data source is full. -->
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -1,121 +0,0 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
/* Same pink accent as Share Products — this page is part of that area, not a separate one. */
:host {
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* Financial-year filter chips. A dividend decision belongs to a year, so the year is the primary
way an officer narrows this register. */
.apd-years {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.apd-years-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-right: 4px;
}
.apd-year-chip {
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: 999px;
padding: 5px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--pl-accent);
color: var(--pl-accent);
}
&--on {
background: var(--pl-accent);
border-color: var(--pl-accent);
color: #fff;
}
}
/* Amber rather than the default pill colour: a declaration awaiting approval has not reached any
member yet, and should not read the same as one that has. */
.apd-status--pending {
background: #fef3c7;
color: #b45309;
}
.apd-pending {
color: #b45309;
font-weight: 500;
}
/* Right-aligned so it reads as the row's action rather than another data column. */
.apd-action {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #e2e8f0;
background: #fff;
color: var(--pl-accent);
border-radius: 6px;
padding: 5px 12px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--pl-accent);
background: var(--pl-accent-l);
}
fa-icon {
font-size: 11px;
}
}
.apd-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 20px;
color: #64748b;
text-align: center;
fa-icon {
font-size: 32px;
color: #cbd5e1;
}
p {
margin: 0;
font-size: 14px;
}
&--error fa-icon {
color: #f59e0b;
}
}
@@ -1,200 +0,0 @@
/**
* 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, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortHeader, MatSortModule } from '@angular/material/sort';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Imports */
import { ProductsService } from '../../products.service';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
/**
* The society's dividend register, across every share product.
*
* A co-operative's board resolves one dividend rate for a financial year — not one rate per share
* product — so this is the level the decision is actually taken and recorded at. Each product still
* carries its own declaration row underneath, because share capital sits in a separate GL per
* product and the per-member allocation runs per product; what is shared is the rate and the period.
*
* Declarations for the same financial year are grouped, so a year that touched two products reads
* as one decision with a split rather than as two unrelated entries.
*/
@Component({
selector: 'mifosx-all-products-dividends',
templateUrl: './all-products-dividends.component.html',
styleUrls: ['./all-products-dividends.component.scss'],
imports: [
CommonModule,
FormsModule,
RouterLink,
FaIconComponent,
// Individual table directives rather than MatTableModule, matching the Share Products list this
// page sits beside — the module form leaves the header/row defs unresolved here and the table
// renders nothing at all, not even headers.
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatSort,
MatSortHeader,
MatSortModule,
MatPaginatorModule,
MatTooltipModule,
MatProgressBarModule,
DateFormatPipe,
HasPermissionDirective
]
})
export class AllProductsDividendsComponent implements OnInit {
private productsService = inject(ProductsService);
private router = inject(Router);
/** Every declaration across all products, newest period first. */
dividends: any[] = [];
dataSource = new MatTableDataSource<any>([]);
displayedColumns: string[] = [
'financialYear',
'productName',
'period',
'amount',
'memberCount',
'status',
'actions'
];
loading = true;
loadFailed = false;
/** Distinct financial years present, for the filter chips. */
financialYears: string[] = [];
selectedYear = '';
// The paginator sits outside the table's @if so it resolves statically; the sort lives on the
// table inside it, so it only exists once there are rows to show — applyYearFilter re-attaches
// both every time the rows change, which is when the sort first becomes available.
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
ngOnInit(): void {
this.loadDividends();
}
loadDividends(): void {
this.loading = true;
this.loadFailed = false;
this.productsService.getAllProductsDividends().subscribe({
next: (response: any[]) => {
this.dividends = response || [];
this.financialYears = Array.from(new Set(this.dividends.map((d) => d.financialYear)))
.sort()
.reverse();
this.applyYearFilter();
this.loading = false;
},
error: () => {
// Left empty with a retry rather than showing zeroes, so an unreachable server is never
// mistaken for a year in which no dividend was declared.
this.dividends = [];
this.loadFailed = true;
this.loading = false;
}
});
}
/** Total declared across the rows currently in view. */
get totalDeclared(): number {
return this.visibleRows.reduce((sum, d) => sum + Number(d.amount || 0), 0);
}
/** Declarations still awaiting approval — these have not reached members yet. */
get pendingCount(): number {
return this.visibleRows.filter((d) => !d.approved).length;
}
/** Members receiving a dividend across the rows in view. */
get memberCount(): number {
return this.visibleRows.reduce((sum, d) => sum + Number(d.memberCount || 0), 0);
}
/** Products covered by the rows in view. */
get productCount(): number {
return new Set(this.visibleRows.map((d) => d.productId)).size;
}
private get visibleRows(): any[] {
return this.selectedYear ? this.dividends.filter((d) => d.financialYear === this.selectedYear) : this.dividends;
}
selectYear(year: string): void {
this.selectedYear = this.selectedYear === year ? '' : year;
this.applyYearFilter();
}
/**
* Swaps the rows in place rather than rebuilding the data source, so the paginator and sort stay
* attached — a fresh MatTableDataSource loses both unless they are re-wired every time.
*/
private applyYearFilter(): void {
this.dataSource.data = this.visibleRows;
this.dataSource.paginator = this.paginator;
// Deferred a tick: the sort directive is inside the table's @if, so it does not exist until
// this change detection pass has rendered the rows we just set.
setTimeout(() => {
this.dataSource.sort = this.sort;
});
}
applyFilter(filterValue: string): void {
this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
* Opens a declaration in the existing per-product view, which holds the member-by-member
* allocation and the approve action.
*/
viewDividend(row: any): void {
this.router.navigate(
[
'/products',
'share-products',
row.productId,
'dividends',
row.dividendId
],
{ queryParams: { status: row.approved ? 'Approved' : 'Initiated' } }
);
}
}
@@ -1,217 +0,0 @@
<!--
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="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div class="pl-header-text">
<h2>Declare Dividend</h2>
<p>One rate for the year, applied to every share product at once.</p>
</div>
</div>
</div>
@if (result) {
<!-- The outcome, product by product. Which products were skipped and why matters as much as
which succeeded, so both are shown rather than just a success count. -->
<div class="pl-card dd-panel">
<div class="dd-result-head">
<fa-icon icon="check-circle" class="dd-result-ic"></fa-icon>
<div>
<h3>Dividend declared for {{ result.financialYear }}</h3>
<p>
{{ result.declarationsCreated }}
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
before members can claim.
</p>
</div>
</div>
<table class="dd-table">
<tbody>
@for (row of result.created; track row.productId) {
<tr>
<td>{{ row.productName }}</td>
<td class="dd-amount">{{ row.amount | number: '1.2-2' }}</td>
<td class="dd-ok">Declared</td>
</tr>
}
@for (row of result.skipped; track row.productId) {
<tr class="dd-skipped">
<td>{{ row.productName }}</td>
<td class="dd-amount">—</td>
<td class="dd-reason">{{ row.reason }}</td>
</tr>
}
</tbody>
</table>
<div class="dd-actions">
<button mat-raised-button color="primary" type="button" (click)="done()">View Dividends</button>
</div>
</div>
} @else {
<form [formGroup]="declareForm" (ngSubmit)="submit()">
<div class="pl-card dd-panel">
<div class="dd-fields">
<mat-form-field appearance="outline">
<mat-label>Financial Year</mat-label>
<mat-select formControlName="financialYear">
@for (year of financialYears; track year.label) {
<mat-option [value]="year.label">{{ year.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="dd-basis">
<span class="dd-basis-label">Dividend Basis</span>
<mat-radio-group formControlName="dividendBasis">
<mat-radio-button value="RATE_ON_CAPITAL">Rate on share capital</mat-radio-button>
<mat-radio-button value="PERCENT_OF_PROFIT">Percentage of distributable profit</mat-radio-button>
</mat-radio-group>
<p class="dd-hint">
@if (declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT') {
A share of the year's distributable profit, split across products in proportion to the capital each
carries.
} @else {
Each member receives this rate on their own paid-up capital, pro-rated for how long they held it.
}
</p>
</div>
<div class="dd-fields">
<mat-form-field appearance="outline">
<mat-label>
{{
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
? 'Percentage of Profit'
: 'Dividend Rate (% per annum)'
}}
</mat-label>
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
<span matSuffix>&nbsp;%&nbsp;</span>
</mat-form-field>
</div>
@if (loadingPreview) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
@if (previewError) {
<div class="dd-note dd-note--error">{{ previewError }}</div>
}
@if (preview && !loadingPreview) {
<!-- The appropriation waterfall. Shown in full because the officer is approving a
division of the society's profit, not a single number: ₹2,22,000 on its own gives no
way to tell a small profit from a large one already heavily appropriated, and the
reserve and education rates are configurable so their amounts cannot be inferred. -->
<div class="dd-preview">
<div class="dd-preview-head">Where the money comes from</div>
<div class="dd-preview-row">
<span>
Net profit for {{ preview.financialYear }}
<span class="dd-gl">{{ preview.profitGlCode }}</span>
</span>
<span class="dd-amount">{{ preview.netProfit | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less statutory reserve ({{ preview.statutoryReservePercentage | number: '1.0-2' }}%)</span>
<span class="dd-amount">− {{ preview.statutoryReserveAmount | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less education fund ({{ preview.educationFundPercentage | number: '1.0-2' }}%)</span>
<span class="dd-amount">− {{ preview.educationFundAmount | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--subtotal">
<span>Distributable profit</span>
<span class="dd-amount">{{ preview.distributableProfit | number: '1.2-2' }}</span>
</div>
@if (declareForm.value.dividendBasis === 'RATE_ON_CAPITAL') {
<div class="dd-preview-row dd-preview-row--muted">
<span>Weighted share capital</span>
<span class="dd-amount">{{ preview.totalWeightedCapital | number: '1.2-2' }}</span>
</div>
}
</div>
<div class="dd-preview">
<div class="dd-preview-head">
{{
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
? (declareForm.value.ratePercentage || 0) + '% of distributable profit, split by capital'
: (declareForm.value.ratePercentage || 0) + '% on each member’s capital'
}}
</div>
@for (product of eligibleProducts; track product.productId) {
<div class="dd-preview-row">
<span>{{ product.productName }}</span>
<span class="dd-amount">{{ product.amount | number: '1.2-2' }}</span>
</div>
}
@if (eligibleProducts.length > 0) {
<div class="dd-preview-row dd-preview-row--total">
<span>Total to declare</span>
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
</div>
<!-- What stays behind. Called out because on approval it leaves the distributable
pool for good — no later year's dividend can draw on it. -->
<div class="dd-preview-row dd-preview-row--muted">
<span>Stays in profit (swept to Bank Profit on approval)</span>
<span class="dd-amount">{{ preview.undistributedRemainder | number: '1.2-2' }}</span>
</div>
}
@for (product of skippedProducts; track product.productId) {
<div class="dd-preview-row dd-preview-row--skipped">
<span>{{ product.productName }}</span>
<span class="dd-reason">
{{ product.alreadyDeclared ? 'Already declared for this year' : product.reason }}
</span>
</div>
}
@if (preview.exceedsDistributableProfit) {
<div class="dd-note dd-note--error">
This is more than the distributable profit for {{ preview.financialYear }}. The declaration will be
rejected.
</div>
}
@if (eligibleProducts.length === 0) {
<div class="dd-note dd-note--error">
No share product is eligible for this year, so there is nothing to declare.
</div>
}
</div>
}
@if (submitError) {
<div class="dd-note dd-note--error">{{ submitError }}</div>
}
<div class="dd-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" type="submit" [disabled]="!canSubmit">
{{ submitting ? 'Declaring…' : 'Declare Dividend' }}
</button>
</div>
</div>
</form>
}
</div>
@@ -1,250 +0,0 @@
/**
* 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/.
*/
@use 'assets/styles/product-list' as pl;
@include pl.product-list-styles;
:host {
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* A single-column form reads better centred than pinned to the left edge of a 1400px page — the
eye has nowhere else to go, and a wide empty right margin makes the form look unfinished. */
.dd-panel {
max-width: 720px;
margin: 0 auto;
padding: 22px 24px;
}
/* The header belongs over the form, not over the full page width, or it detaches from it. */
.pl-header {
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
/* The panel is wrapped in a <form>, so centring it needs the form to be the full-width block that
does the centring — margin:auto on the panel alone has no effect inside a default-width form. */
form {
display: block;
width: 100%;
}
.dd-fields {
display: flex;
flex-wrap: wrap;
gap: 16px;
mat-form-field {
flex: 1 1 240px;
}
}
.dd-hint {
margin: 0 0 16px;
font-size: 13px;
color: #64748b;
}
/* Which basis is chosen changes what the percentage means, so it sits above the rate field rather
than below it — the officer picks the meaning before typing the number. */
.dd-basis {
margin: 4px 0 8px;
mat-radio-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.dd-hint {
margin: 6px 0 0;
}
}
.dd-basis-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
/* The cost of the rate, per product. The officer is committing to a rate, but what leaves the
society is an amount — so the amount is shown before the commit, not after. */
.dd-preview {
margin-top: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 16px;
}
.dd-preview-head {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 10px;
}
.dd-preview-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 5px 0;
font-size: 14px;
/* Red, and signed, so an appropriation reads as money leaving the pool rather than another
figure to add up. */
&--deduction {
color: #b42318;
}
/* The line the deductions arrive at — separated from them, but lighter than the final total,
since it is a step in the arithmetic rather than the answer. */
&--subtotal {
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid #cbd5e1;
font-weight: 600;
color: #0f172a;
}
/* Context rather than part of the sum. */
&--muted {
color: #64748b;
font-size: 13px;
}
&--total {
margin-top: 6px;
padding-top: 10px;
border-top: 2px solid #cbd5e1;
font-weight: 700;
color: var(--pl-accent);
}
/* Muted, not hidden: a product that is being skipped still needs to be visibly accounted for. */
&--skipped {
color: #94a3b8;
font-size: 13px;
}
}
.dd-amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Names the account the profit is actually read from, so the figure can be traced to the ledger
rather than taken on trust. */
.dd-gl {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* Two stacked panels: where the money comes from, then how it is split. */
.dd-preview + .dd-preview {
margin-top: 12px;
}
.dd-reason {
font-size: 12.5px;
color: #94a3b8;
text-align: right;
}
.dd-note {
margin-top: 12px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
background: #f1f5f9;
color: #334155;
&--error {
background: #fef2f2;
color: #b42318;
}
}
.dd-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
/* Outcome view */
.dd-result-head {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 18px;
h3 {
margin: 0 0 4px;
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
p {
margin: 0;
font-size: 13.5px;
color: #64748b;
}
}
.dd-result-ic {
font-size: 26px;
color: #16a34a;
margin-top: 2px;
}
.dd-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
td {
padding: 9px 8px;
border-bottom: 1px solid #f1f5f9;
}
td:nth-child(2) {
text-align: right;
width: 140px;
}
td:nth-child(3) {
text-align: right;
width: 220px;
}
}
.dd-ok {
color: #16a34a;
font-weight: 600;
font-size: 13px;
}
.dd-skipped {
color: #94a3b8;
}
@@ -1,208 +0,0 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatOptionModule } from '@angular/material/core';
import { MatRadioModule } from '@angular/material/radio';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Imports */
import { ProductsService } from '../../../products.service';
import { SettingsService } from '../../../../settings/settings.service';
/**
* Declares one dividend across every share product for a financial year.
*
* The board resolves a rate, not an amount — "9% for 2025-26" — and what that costs depends on how
* much capital each product's members held and for how long. So the officer enters the rate and the
* year, and the preview shows what it works out to per product before anything is committed. That
* preview is the point of the screen: a rate looks harmless until you see the amount it implies.
*/
@Component({
selector: 'mifosx-declare-dividend',
templateUrl: './declare-dividend.component.html',
styleUrls: ['./declare-dividend.component.scss'],
imports: [
CommonModule,
ReactiveFormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatOptionModule,
MatRadioModule,
MatProgressBarModule,
FaIconComponent
]
})
export class DeclareDividendComponent implements OnInit {
private formBuilder = inject(FormBuilder);
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private router = inject(Router);
declareForm: FormGroup;
/** The financial years a dividend may be declared for. */
financialYears: { label: string; start: string; end: string }[] = [];
/** What the entered rate works out to, per product. Null until a rate and year are both chosen. */
preview: any = null;
loadingPreview = false;
previewError: string | null = null;
submitting = false;
submitError: string | null = null;
/** Set once the declaration has gone through, so the outcome per product can be shown. */
result: any = null;
ngOnInit(): void {
this.declareForm = this.formBuilder.group({
financialYear: [
'',
Validators.required
],
// Rate on capital is the default because it is what a dividend rate means in an AGM
// resolution: each member receives that rate on their own capital, not a slice of a pool.
dividendBasis: ['RATE_ON_CAPITAL'],
ratePercentage: [
'',
[
Validators.required,
Validators.min(0.01),
Validators.max(100)
]
]
});
this.buildFinancialYears();
this.declareForm.valueChanges.subscribe(() => this.loadPreview());
}
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
* The current year is included because a society may declare an interim dividend part-way through,
* and five back covers a declaration delayed by an AGM that ran late. Built from the business date
* rather than the browser's clock, so the list matches the year the branch is operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
// April starts the Indian financial year, so anything before it still belongs to the prior year.
const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1;
this.financialYears = [];
for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) {
this.financialYears.push({
label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`,
start: `${startYear}-04-01`,
end: `${startYear + 1}-03-31`
});
}
}
private selectedYear(): { label: string; start: string; end: string } | undefined {
return this.financialYears.find((y) => y.label === this.declareForm.value.financialYear);
}
/** What this rate costs, per product, before anything is created. */
loadPreview(): void {
const year = this.selectedYear();
const rate = Number(this.declareForm.value.ratePercentage);
if (!year || !rate || rate <= 0) {
this.preview = null;
return;
}
this.loadingPreview = true;
this.previewError = null;
this.productsService
.previewAllProductsDividend(year.start, year.end, rate, this.declareForm.value.dividendBasis)
.subscribe({
next: (response: any) => {
this.preview = response;
this.loadingPreview = false;
},
error: (error: any) => {
// Cleared rather than left stale, so a failed refresh can never be read as the cost of the
// rate now on screen.
this.preview = null;
this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.';
this.loadingPreview = false;
}
});
}
/** Products that will actually receive a declaration. */
get eligibleProducts(): any[] {
return (this.preview?.products ?? []).filter((p: any) => p.eligible && !p.alreadyDeclared);
}
/** Products that will be skipped, with the reason shown alongside. */
get skippedProducts(): any[] {
return (this.preview?.products ?? []).filter((p: any) => !p.eligible || p.alreadyDeclared);
}
/** Total across only the products that will actually be declared. */
get payableTotal(): number {
return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
get canSubmit(): boolean {
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0;
}
submit(): void {
const year = this.selectedYear();
if (!year || !this.canSubmit) {
return;
}
this.submitting = true;
this.submitError = null;
this.productsService
.createAllProductsDividend({
dividendPeriodStartDate: year.start,
dividendPeriodEndDate: year.end,
dividendBasis: this.declareForm.value.dividendBasis,
ratePercentage: String(this.declareForm.value.ratePercentage)
})
.subscribe({
next: (response: any) => {
this.result = response;
this.submitting = false;
},
error: (error: any) => {
this.submitError = error?.error?.defaultUserMessage ?? 'The dividend could not be declared.';
this.submitting = false;
}
});
}
done(): void {
this.router.navigate([
'/products',
'share-products',
'dividends'
]);
}
cancel(): void {
this.done();
}
}
@@ -11,23 +11,44 @@
<form [formGroup]="createDividendForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<!-- A dividend is declared for a financial year, and the whole flow depends on that: the
profit it is measured against is the year's closed profit, and the residue and
undistributed remainder are filed under the year the period ends in. Picking the year
sets both dates, so a part-year period cannot be entered by mistake. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Financial Year' | translate }}</mat-label>
<mat-select
[value]="selectedFinancialYear"
(selectionChange)="onFinancialYearChange($event.value)"
<mat-form-field (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Dividend Period Start Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="startDatePicker"
required
>
@for (fy of financialYears; track fy.label) {
<mat-option [value]="fy.label"
>FY {{ fy.label }} ({{ fy.startLabel }} &ndash; {{ fy.endLabel }})</mat-option
>
}
</mat-select>
formControlName="dividendPeriodStartDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
@if (createDividendForm.controls.dividendPeriodStartDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Starting Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field (click)="endDatePicker.open()">
<mat-label>{{ 'labels.inputs.Dividend Period End Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="endDatePicker"
required
formControlName="dividendPeriodEndDate"
/>
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
@if (createDividendForm.controls.dividendPeriodEndDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.End Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (overlapError) {
@@ -42,125 +63,15 @@
<p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not fetch bank profit for this period' | translate }}.
</p>
} @else if (profitBreakdown !== null) {
<!-- The statutory appropriations a co-operative society must make before any dividend
may be declared, then what other products have already committed, leaving the
ceiling this declaration has to stay within. -->
<div class="dividend-waterfall">
<div class="dividend-waterfall__row">
@if (profitBreakdown.yearClosed) {
<span
>{{ 'labels.inputs.Net Profit' | translate }} &mdash; FY {{ profitBreakdown.financialYear }} ({{
'labels.inputs.closed books' | translate
}})</span
>
} @else {
<span>{{ 'labels.inputs.Net Profit for this Period' | translate }}</span>
}
<span>{{ profitBreakdown.netProfit | number: '1.2-2' }}</span>
</div>
<!-- Where the profit figure came from. A year that has been closed gives the audited
number out of the Bank Profit GL; an open year can only offer a P&L that is still
moving, which makes any declaration against it an interim one. -->
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
@if (profitBreakdown.yearClosed) {
<span>{{ 'labels.inputs.From the Bank Profit GL after year-end closing' | translate }}</span>
} @else if (profitBreakdown.coversWholeFinancialYear) {
<span
>{{ 'labels.inputs.FY' | translate }} {{ profitBreakdown.financialYear }}
{{
'labels.inputs.has not been closed yet — this is the open profit and loss, not an audited figure'
| translate
}}</span
>
} @else {
<span>{{
'labels.inputs.Interim period — from the open profit and loss, not an audited figure' | translate
}}</span>
}
<span></span>
</div>
@if (profitBreakdown.statutoryReservePercentage > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span
>&minus; {{ 'labels.inputs.Statutory Reserve Fund' | translate }} ({{
profitBreakdown.statutoryReservePercentage
}}%)</span
>
<span>{{ profitBreakdown.statutoryReserveAmount | number: '1.2-2' }}</span>
</div>
}
@if (profitBreakdown.educationFundPercentage > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span
>&minus; {{ 'labels.inputs.Education Fund' | translate }} ({{
profitBreakdown.educationFundPercentage
}}%)</span
>
<span>{{ profitBreakdown.educationFundAmount | number: '1.2-2' }}</span>
</div>
}
<div class="dividend-waterfall__row dividend-waterfall__row--subtotal">
<span>{{ 'labels.inputs.Distributable Profit' | translate }}</span>
<span>{{ profitBreakdown.distributableProfit | number: '1.2-2' }}</span>
</div>
@if (profitBreakdown.alreadyDeclared > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span>&minus; {{ 'labels.inputs.Already declared for other products' | translate }}</span>
<span>{{ profitBreakdown.alreadyDeclared | number: '1.2-2' }}</span>
</div>
@for (declaration of profitBreakdown.existingDeclarations; track declaration.declarationId) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ declaration.productName }} ({{ declaration.periodStartDate }} &ndash;
{{ declaration.periodEndDate }})</span
>
<span>{{ declaration.amount | number: '1.2-2' }}</span>
</div>
}
}
<div class="dividend-waterfall__row dividend-waterfall__row--total">
<span>{{ 'labels.inputs.Available for this declaration' | translate }}</span>
<span>{{ profitBreakdown.availableForDeclaration | number: '1.2-2' }}</span>
</div>
</div>
} @else if (netProfit !== null) {
<p class="dividend-profit-note">
{{ 'labels.inputs.Bank Profit for this Period' | translate }}:
<strong>{{ netProfit | number: '1.2-2' }}</strong>
</p>
}
<!-- Which question the percentage below answers. RATE_ON_CAPITAL is the default because
it is what a rate in an AGM resolution promises each member. -->
<div class="dividend-mode">
<span class="dividend-mode__label">{{ 'labels.inputs.Dividend Basis' | translate }}</span>
<mat-radio-group [value]="dividendMode" (change)="onModeChange($event.value)">
<mat-radio-button value="RATE_ON_CAPITAL">
{{ 'labels.inputs.Rate on share capital' | translate }}
</mat-radio-button>
<mat-radio-button value="PERCENT_OF_PROFIT">
{{ 'labels.inputs.Percentage of distributable profit' | translate }}
</mat-radio-button>
</mat-radio-group>
<p class="dividend-mode__hint">
@if (dividendMode === 'RATE_ON_CAPITAL') {
{{
'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it'
| translate
}}.
} @else {
{{
'labels.inputs.This share of distributable profit is split across members by holding period — it is not the rate any individual member receives'
| translate
}}.
}
</p>
</div>
<mat-form-field>
<mat-label>
@if (dividendMode === 'RATE_ON_CAPITAL') {
{{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }})
} @else {
{{ 'labels.inputs.Dividend Percentage' | translate }} (%)
}
</mat-label>
<mat-label>{{ 'labels.inputs.Dividend Percentage' | translate }} (%)</mat-label>
<input matInput type="number" min="0" max="100" step="0.01" required formControlName="dividendPercentage" />
@if (createDividendForm.controls.dividendPercentage.hasError('required')) {
<mat-error>
@@ -170,43 +81,6 @@
}
</mat-form-field>
@if (rateCeilingError) {
<p class="dividend-profit-note dividend-profit-note--error">{{ rateCeilingError }}</p>
}
@if (dividendMode === 'RATE_ON_CAPITAL') {
@if (loadingCapital) {
<p class="dividend-profit-note">
{{ 'labels.inputs.Calculating eligible share capital' | translate }}...
</p>
} @else if (capitalLookupError) {
<p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}.
</p>
} @else if (capitalPreview !== null) {
<div class="dividend-waterfall">
<div class="dividend-waterfall__row">
<span>{{ 'labels.inputs.Eligible share capital (weighted)' | translate }}</span>
<span>{{ capitalPreview.weightedCapital | number: '1.2-2' }}</span>
</div>
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span>{{ 'labels.inputs.Eligible accounts' | translate }}</span>
<span>{{ capitalPreview.eligibleAccounts }}</span>
</div>
@if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }}
{{ capitalPreview.minimumActivePeriodDays }}
{{ 'labels.inputs.days' | translate }}</span
>
<span></span>
</div>
}
</div>
}
}
<mat-form-field>
<mat-label
>{{ 'labels.inputs.Dividend Amount' | translate }} ({{
@@ -221,10 +95,6 @@
</mat-error>
}
</mat-form-field>
@if (ceilingError) {
<p class="dividend-profit-note dividend-profit-note--error">{{ ceilingError }}</p>
}
</div>
</mat-card-content>
@@ -232,7 +102,7 @@
<button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || hasBlockingError">
<button mat-raised-button color="primary" [disabled]="!createDividendForm.valid || !!overlapError">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -10,77 +10,6 @@
max-width: 37rem;
}
/* The appropriation waterfall: net profit reduced step by step to the ceiling this declaration
must stay within. Laid out as label/amount pairs so the arithmetic reads down the column. */
.dividend-waterfall {
margin: 0 0 12px;
padding: 10px 12px;
border: 1px solid #d8dee4;
border-radius: 6px;
font-size: 13px;
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
> span:last-child {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&--deduction {
color: #b42318;
}
&--detail {
padding-left: 14px;
color: #667085;
font-size: 12px;
}
&--subtotal {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid #d8dee4;
font-weight: 500;
}
&--total {
margin-top: 4px;
padding-top: 6px;
border-top: 2px solid #98a2b3;
font-weight: 600;
color: #1a7f37;
}
}
}
.dividend-mode {
margin: 0 0 12px;
&__label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: #344054;
}
mat-radio-group {
display: flex;
flex-direction: column;
gap: 2px;
}
&__hint {
margin: 6px 0 0;
font-size: 12px;
color: #667085;
}
}
.dividend-profit-note {
margin: 0 0 12px;
padding: 8px 12px;
@@ -10,8 +10,6 @@
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { DecimalPipe } from '@angular/common';
import { Dates } from 'app/core/utils/dates';
/** Custom Services. */
@@ -28,10 +26,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './create-dividend.component.html',
styleUrls: ['./create-dividend.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
DecimalPipe
...STANDALONE_SHARED_IMPORTS
]
})
export class CreateDividendComponent implements OnInit {
@@ -52,76 +47,13 @@ export class CreateDividendComponent implements OnInit {
/** Maximum Date allowed. */
maxDate = new Date();
/**
* The full appropriation waterfall for the selected period, from the backend: net profit (posted
* GL journal entries), less the statutory appropriations that legally precede a dividend, less
* dividends already declared for other share products over an overlapping period.
*/
profitBreakdown: {
financialYear: string;
coversWholeFinancialYear: boolean;
/** True once the year's P&L has been closed into the Bank Profit GL — the audited figure. */
yearClosed: boolean;
profitSource: 'BANK_PROFIT_GL' | 'OPEN_PROFIT_AND_LOSS';
netProfit: number;
statutoryReservePercentage: number;
statutoryReserveAmount: number;
educationFundPercentage: number;
educationFundAmount: number;
distributableProfit: number;
alreadyDeclared: number;
availableForDeclaration: number;
maximumRatePercentage: number;
existingDeclarations: {
productName: string;
amount: number;
periodStartDate: string;
periodEndDate: string;
}[];
} | null = null;
/** Real bank profit for the selected period (fetched from posted GL journal entries). */
netProfit: number | null = null;
/** True while the profit figure for the selected period is being fetched. */
loadingProfit = false;
/** Set if the profit lookup fails, so the officer knows the amount below isn't auto-computed. */
profitLookupError = false;
/**
* Weighted paid-up capital for the period, for RATE_ON_CAPITAL mode. `requiredAmount` is what the
* entered rate actually costs to honour.
*/
capitalPreview: {
weightedCapital: number;
requiredAmount: number | null;
eligibleAccounts: number;
minimumActivePeriodDays: number;
} | null = null;
loadingCapital = false;
capitalLookupError = false;
/**
* RATE_ON_CAPITAL is the default because it is what a dividend rate means in an AGM resolution:
* each member receives the declared rate on their own paid-up capital, pro-rated for how long
* they held it. PERCENT_OF_PROFIT sizes the pool as a share of profit instead — legitimate when
* the committee decides "distribute this much of the profit", but the resulting per-member rate
* then depends on how many other members hold shares, so it is not the rate members are promised.
*/
dividendMode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT' = 'RATE_ON_CAPITAL';
/**
* Financial years a dividend can be declared for, newest first.
*
* A dividend belongs to a financial year, and everything downstream depends on that: the profit it is measured
* against is the year's closed profit, and both the rounding residue and the undistributed remainder are filed
* under the year the period ends in. Choosing the year rather than two loose dates makes a part-year period —
* which none of that arithmetic would hold for — impossible to enter.
*/
financialYears: { label: string; startLabel: string; endLabel: string; start: Date; end: Date }[] = [];
selectedFinancialYear: string | null = null;
/** Set when the computed amount exceeds what may still be distributed for this period. */
ceilingError: string | null = null;
/** Set when the entered rate exceeds the ceiling in the governing Co-operative Societies Act. */
rateCeilingError: string | null = null;
/** Every dividend already declared for this product, used to block overlapping periods. */
private existingDividendPeriods: { start: Date; end: Date }[] = [];
/** Set when the currently selected period overlaps an existing declaration for this product. */
@@ -145,57 +77,17 @@ export class CreateDividendComponent implements OnInit {
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.setDividendForm();
this.buildFinancialYears();
this.loadExistingDividendPeriods();
// Both dates now move together as one financial year is chosen, so watching the end date alone
// is enough — subscribing to both would fire the profit and capital lookups twice per change.
this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(() => {
const onPeriodChange = () => {
this.fetchProfitForSelectedPeriod();
this.checkOverlap();
});
};
this.createDividendForm.get('dividendPeriodStartDate').valueChanges.subscribe(onPeriodChange);
this.createDividendForm.get('dividendPeriodEndDate').valueChanges.subscribe(onPeriodChange);
this.createDividendForm.get('dividendPercentage').valueChanges.subscribe(() => this.recomputeDividendAmount());
}
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
* The current year is included because a society may declare an interim dividend part-way through, and five back
* covers a declaration delayed by an AGM that ran late. Built from the business date rather than the browser's
* clock, so the list matches the year the branch is actually operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
// April starts the Indian financial year, so anything before it still belongs to the prior year.
const currentStartYear = businessDate.getMonth() >= 3 ? businessDate.getFullYear() : businessDate.getFullYear() - 1;
this.financialYears = [];
for (let startYear = currentStartYear; startYear > currentStartYear - 6; startYear--) {
const start = new Date(startYear, 3, 1);
const end = new Date(startYear + 1, 2, 31);
this.financialYears.push({
label: `${startYear}-${String((startYear + 1) % 100).padStart(2, '0')}`,
startLabel: this.toIsoDate(start),
endLabel: this.toIsoDate(end),
start,
end
});
}
}
/** Sets both period dates from the chosen year, which is what drives the profit lookup. */
onFinancialYearChange(label: string): void {
const fy = this.financialYears.find((y) => y.label === label);
if (!fy) {
return;
}
this.selectedFinancialYear = label;
this.createDividendForm.patchValue({
dividendPeriodStartDate: fy.start,
dividendPeriodEndDate: fy.end
});
}
/** Parses a Fineract date field, which may arrive as `[yyyy, MM, dd]` or an ISO string. */
private parseFineractDate(value: any): Date | null {
if (!value) {
@@ -280,9 +172,8 @@ export class CreateDividendComponent implements OnInit {
}
/**
* Looks up how much profit is actually available to distribute for the selected period, once both
* dates are set — net profit from posted GL entries, less the statutory appropriations that
* legally precede a dividend, less what other products have already been declared.
* Looks up the bank's real net profit for the selected dividend period, once both dates are
* set, so the officer can base the dividend on actual profit instead of guessing an amount.
*/
fetchProfitForSelectedPeriod(): void {
const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value;
@@ -292,145 +183,36 @@ export class CreateDividendComponent implements OnInit {
}
this.loadingProfit = true;
this.profitLookupError = false;
this.accountingService
.getDistributableProfit(this.toIsoDate(start), this.toIsoDate(end), this.shareProductData.id)
.subscribe({
next: (response: any) => {
this.profitBreakdown = {
financialYear: response.financialYear,
coversWholeFinancialYear: !!response.coversWholeFinancialYear,
yearClosed: !!response.yearClosed,
profitSource: response.profitSource,
netProfit: Number(response.netProfit) || 0,
statutoryReservePercentage: Number(response.statutoryReservePercentage) || 0,
statutoryReserveAmount: Number(response.statutoryReserveAmount) || 0,
educationFundPercentage: Number(response.educationFundPercentage) || 0,
educationFundAmount: Number(response.educationFundAmount) || 0,
distributableProfit: Number(response.distributableProfit) || 0,
alreadyDeclared: Number(response.alreadyDeclared) || 0,
availableForDeclaration: Number(response.availableForDeclaration) || 0,
maximumRatePercentage: Number(response.maximumRatePercentage) || 0,
existingDeclarations: response.existingDeclarations || []
};
this.loadingProfit = false;
this.recomputeDividendAmount();
},
error: () => {
this.profitBreakdown = null;
this.loadingProfit = false;
this.profitLookupError = true;
}
});
this.fetchCapitalPreview();
this.accountingService.getProfitAndLoss(this.toIsoDate(start), this.toIsoDate(end)).subscribe({
next: (response: any) => {
this.netProfit = Number(response.netProfit) || 0;
this.loadingProfit = false;
this.recomputeDividendAmount();
},
error: () => {
this.netProfit = null;
this.loadingProfit = false;
this.profitLookupError = true;
}
});
}
/**
* Weighted paid-up capital for the period. Fetched independently of the profit lookup because it
* answers a different question — what the members actually hold, rather than what the society
* earned — and RATE_ON_CAPITAL needs both.
*/
fetchCapitalPreview(): void {
const start: Date = this.createDividendForm.get('dividendPeriodStartDate').value;
const end: Date = this.createDividendForm.get('dividendPeriodEndDate').value;
if (!(start instanceof Date) || !(end instanceof Date)) {
return;
}
this.loadingCapital = true;
this.capitalLookupError = false;
this.productService
.getDividendCapitalPreview(this.shareProductData.id, this.toIsoDate(start), this.toIsoDate(end))
.subscribe({
next: (response: any) => {
this.capitalPreview = {
weightedCapital: Number(response.weightedCapital) || 0,
requiredAmount: null,
eligibleAccounts: Number(response.eligibleAccounts) || 0,
minimumActivePeriodDays: Number(response.minimumActivePeriodDays) || 0
};
this.loadingCapital = false;
this.recomputeDividendAmount();
},
error: () => {
this.capitalPreview = null;
this.loadingCapital = false;
this.capitalLookupError = true;
}
});
}
/** Switches between the two ways of sizing the pool and recomputes under the new one. */
onModeChange(mode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT'): void {
this.dividendMode = mode;
this.recomputeDividendAmount();
}
/**
* Computes the dividend amount under whichever mode is selected, then checks it against both
* ceilings — the rate cap in the governing Act, and the profit still available to distribute.
*
* RATE_ON_CAPITAL: amount = weighted paid-up capital x rate. Every member then receives that rate
* on their own capital, pro-rated by holding period.
*
* PERCENT_OF_PROFIT: amount = distributable profit x percentage. Note this is applied to the
* DISTRIBUTABLE figure, not raw net profit — the statutory appropriations come out first either
* way.
*/
/** Dividend Amount = Net Profit x Dividend Percentage / 100 — no manual entry needed. */
recomputeDividendAmount(): void {
const percentage = Number(this.createDividendForm.get('dividendPercentage').value);
this.ceilingError = null;
this.rateCeilingError = null;
if (!percentage || percentage <= 0) {
if (this.netProfit === null || !percentage || percentage <= 0) {
this.createDividendForm.get('dividendAmount').setValue('');
if (this.capitalPreview) {
this.capitalPreview.requiredAmount = null;
}
return;
}
// The rate cap governs a rate promised on capital. Under PERCENT_OF_PROFIT the number entered
// is a share of profit, not a rate any member receives, so the cap does not apply to it.
const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0;
if (this.dividendMode === 'RATE_ON_CAPITAL' && maximumRate > 0 && percentage > maximumRate) {
this.rateCeilingError = `The maximum dividend rate permitted is ${maximumRate}%. Reduce the rate to declare this dividend.`;
}
let amount: number | null = null;
if (this.dividendMode === 'RATE_ON_CAPITAL') {
if (this.capitalPreview === null) {
this.createDividendForm.get('dividendAmount').setValue('');
return;
}
amount = (this.capitalPreview.weightedCapital * percentage) / 100;
this.capitalPreview.requiredAmount = Math.round(amount * 100) / 100;
} else {
if (this.profitBreakdown === null) {
this.createDividendForm.get('dividendAmount').setValue('');
return;
}
amount = (this.profitBreakdown.distributableProfit * percentage) / 100;
}
const rounded = Math.round(amount * 100) / 100;
this.createDividendForm.get('dividendAmount').setValue(rounded);
if (this.profitBreakdown && rounded > this.profitBreakdown.availableForDeclaration) {
this.ceilingError =
`This dividend of ${rounded.toFixed(2)} exceeds the ${this.profitBreakdown.availableForDeclaration.toFixed(2)} ` +
`still available to distribute for this period. Reduce the rate, or declare for a period with more profit.`;
}
}
/** True when any ceiling or overlap rule blocks submission. */
get hasBlockingError(): boolean {
return !!this.overlapError || !!this.ceilingError || !!this.rateCeilingError;
const amount = (this.netProfit * percentage) / 100;
this.createDividendForm.get('dividendAmount').setValue(Math.round(amount * 100) / 100);
}
/**
* Submits Create Dividend form.
*/
submit() {
if (this.hasBlockingError) {
if (this.overlapError) {
return;
}
const createDividendFormData = this.createDividendForm.value;
@@ -21,10 +21,6 @@
<strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'product' : 'products' }}</span>
</div>
<button class="pl-secondary-btn" type="button" [routerLink]="['/products', 'share-products', 'dividends']">
<fa-icon icon="hand-holding-usd"></fa-icon>
<span>Dividends</span>
</button>
<button
#buttonCreateShareProduct
class="pl-create-btn"
@@ -60,26 +56,6 @@
<div class="pl-kpi-l">Showing after filter</div>
</div>
</div>
<!-- Hidden rather than zeroed when the figure is unavailable, so a failed fetch is never read
as "no dividend has been declared". -->
@if (dividendSummary) {
<div
class="pl-kpi sp-kpi--dividend"
[routerLink]="['/products', 'share-products', 'dividends']"
matTooltip="View all dividend declarations"
>
<div class="pl-kpi-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ dividendSummary.totalDeclared | number: '1.2-2' }}</div>
<div class="pl-kpi-l">
Dividends Declared
@if (dividendSummary.pendingCount > 0) {
<span class="sp-kpi-pending">· {{ dividendSummary.pendingCount }} awaiting approval</span>
}
</div>
</div>
</div>
}
</div>
<div class="pl-card">
@@ -15,31 +15,3 @@
--pl-accent: #be185d;
--pl-accent-l: #fce7f3;
}
/* Dividend card. Pink to match the share accent rather than reusing one of the three colours
already carrying a meaning in this row. */
.sp-kpi--dividend {
cursor: pointer;
.pl-kpi-ic {
background: #fce7f3;
color: #be185d;
}
&:hover {
border-color: #be185d;
}
}
/* Only appears when something is actually waiting, so it reads as a nudge rather than a permanent
part of the label. */
.sp-kpi-pending {
color: #b45309;
font-weight: 500;
}
/* The shared grid is three columns; this page has a fourth card once the dividend figure loads.
Auto-fit keeps them even at any count instead of leaving the fourth stranded on its own row. */
.pl-kpis {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
@@ -74,14 +74,6 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
filteredCount = 0;
/**
* Dividends declared across all share products.
*
* Left null when the figure cannot be fetched, so the card hides rather than showing a 0.00 that
* would read as "no dividend was declared" when the truth is "we could not find out".
*/
dividendSummary: { totalDeclared: number; declarationCount: number; pendingCount: number } | null = null;
get totalCount(): number {
return this.shareProductsData?.length || 0;
}
@@ -131,7 +123,6 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.filteredCount = this.totalCount;
this.loadDividendSummary();
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
@@ -139,30 +130,6 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
this.dataSource.filter = (filterValue || '').trim().toLowerCase();
}
/**
* What has been declared in dividends across every share product.
*
* Summarised here rather than per row because a dividend is one society-wide decision — the board
* resolves a rate for the year, and it lands on whichever products have members holding shares.
*/
private loadDividendSummary(): void {
this.productsService.getAllProductsDividends().subscribe({
next: (declarations: any[]) => {
const rows = declarations || [];
this.dividendSummary = {
totalDeclared: rows.reduce((sum, d) => sum + Number(d.amount || 0), 0),
declarationCount: rows.length,
pendingCount: rows.filter((d) => !d.approved).length
};
},
// Deliberately silent: this is a summary card beside the product list, not the reason the
// page exists. A failure hides the card instead of blocking the products the officer came for.
error: () => {
this.dividendSummary = null;
}
});
}
/**
* To show popover.
*/
@@ -56,45 +56,6 @@
</div>
</div>
<!-- Why the member rows below do not sum to the declared amount: entitlements are rounded down
to whole rupees so each can be drawn as a Demand Draft, and any left under the minimum worth
drawing one for is dropped. Both go to Bank Profit at declaration time. Hidden when nothing
was held back, and for declarations made before this was recorded. -->
@if (residue && residue.residueAmount > 0) {
<div class="vd-residue">
<div class="vd-residue__title">{{ 'labels.inputs.Not paid out to members' | translate }}</div>
<div class="vd-residue__rows">
<div class="vd-residue__row">
<span>{{ 'labels.inputs.Declared amount' | translate }}</span>
<span>{{ residue.declaredAmount | number: '1.2-2' }}</span>
</div>
<div class="vd-residue__row">
<span>{{ 'labels.inputs.Allocated to members' | translate }}</span>
<span>{{ residue.allocatedAmount | number: '1.2-2' }}</span>
</div>
<div class="vd-residue__row vd-residue__row--total">
<span>{{ 'labels.inputs.Swept to Bank Profit' | translate }}</span>
<span>{{ residue.residueAmount | number: '1.2-2' }}</span>
</div>
@if (residue.belowMinimumCount > 0) {
<div class="vd-residue__row vd-residue__row--detail">
<span>
{{ 'labels.inputs.of which' | translate }} {{ residue.belowMinimumCount }}
{{ 'labels.inputs.member(s) below the minimum payable amount' | translate }}
</span>
<span>{{ residue.belowMinimumAmount | number: '1.2-2' }}</span>
</div>
}
@if (residue.glTransactionId) {
<div class="vd-residue__row vd-residue__row--detail">
<span>{{ 'labels.inputs.Journal entry' | translate }} {{ residue.glTransactionId }}</span>
<span>{{ residue.sweptToProfitDate }}</span>
</div>
}
</div>
</div>
}
<div class="vd-filter">
<div class="vd-search">
<fa-icon icon="search" class="vd-search__icon"></fa-icon>
@@ -225,56 +225,3 @@ table {
text-align: right;
}
}
/* Why the member rows do not sum to the declared amount — rounding down to whole rupees, plus
entitlements dropped under the minimum worth drawing a draft for. Sits between the summary tiles
and the member table because that is where the question arises. */
.vd-residue {
margin: 0 0 16px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
&__title {
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 8px;
}
&__rows {
display: flex;
flex-direction: column;
gap: 3px;
}
&__row {
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 13px;
color: #334155;
> span:last-child {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&--total {
margin-top: 4px;
padding-top: 6px;
border-top: 1px solid #cbd5e1;
font-weight: 600;
color: #0f172a;
}
&--detail {
padding-left: 12px;
font-size: 12px;
color: #64748b;
}
}
}
@@ -7,7 +7,6 @@
*/
import { Component, OnInit, Input, Output, EventEmitter, ViewChild, inject } from '@angular/core';
import { catchError, of } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
@@ -68,22 +67,6 @@ export class ViewDividendComponent implements OnInit {
isdividendPosted = false;
totalAmount = 0;
/**
* What the declaration did not pay out to members. Entitlements are rounded down to whole rupees
* (a branch cannot draw a Demand Draft for 421.32) and dropped entirely below the minimum worth
* drawing one for, so the member rows below never sum to the declared figure. This explains the
* gap rather than leaving it looking like a discrepancy.
*/
residue: {
declaredAmount: number;
allocatedAmount: number;
residueAmount: number;
belowMinimumCount: number;
belowMinimumAmount: number;
sweptToProfitDate: string | null;
glTransactionId: string | null;
} | null = null;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
displayedColumns: string[] = [
@@ -99,56 +82,24 @@ export class ViewDividendComponent implements OnInit {
this.productsService
.getDividendData(this.overrideProductId, this.overrideDividendId)
.subscribe((dividendData: any) => this.setDividendData(dividendData));
this.loadResidue(this.overrideProductId, this.overrideDividendId);
} else {
this.status = this.route.snapshot.queryParams['status'];
if (this.status && (this.status === 'Dividend Approved' || this.status === 'Dividend Posted')) {
this.isdividendPosted = true;
}
this.route.data.subscribe((data: { dividendData: any }) => this.setDividendData(data.dividendData));
this.loadResidue(
this.route.parent?.parent?.snapshot.paramMap.get('productId'),
this.route.snapshot.paramMap.get('dividendId')
);
}
}
/**
* Declarations made before residue was tracked have no record, and the endpoint returns null for
* them — the panel simply stays hidden rather than showing zeros that would misrepresent them.
*/
private loadResidue(productId: any, dividendId: any): void {
if (!productId || !dividendId) {
return;
}
this.productsService
.getDividendRoundingResidue(productId, dividendId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.residue = res
? {
declaredAmount: Number(res.declaredAmount) || 0,
allocatedAmount: Number(res.allocatedAmount) || 0,
residueAmount: Number(res.residueAmount) || 0,
belowMinimumCount: Number(res.belowMinimumCount) || 0,
belowMinimumAmount: Number(res.belowMinimumAmount) || 0,
sweptToProfitDate: res.sweptToProfitDate ?? null,
glTransactionId: res.glTransactionId ?? null
}
: null;
});
}
private setDividendData(dividendData: any) {
this.dividendData = dividendData;
const rows = dividendData.pageItems ?? [];
this.dataSource = new MatTableDataSource(rows);
this.dataSource = new MatTableDataSource(dividendData.pageItems);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.totalAmount = rows.reduce((sum: number, item: any) => sum + Number(item.amount ?? 0), 0);
// Taken from the members' own rows rather than the ?status= query parameter, which is set by
// whichever screen linked here and can be edited in the URL. A stale or wrong parameter used to
// leave the Approve button on an already-approved declaration — an approval that would then be
// rejected by the server, after the officer had been told it was theirs to make.
this.isdividendPosted = rows.length > 0 && rows.every((item: any) => item.status?.value === 'Dividend Posted');
this.totalAmount = (dividendData.pageItems ?? []).reduce(
(sum: number, item: any) => sum + Number(item.amount ?? 0),
0
);
}
statusChipClass(status: string): string {
@@ -172,14 +123,7 @@ export class ViewDividendComponent implements OnInit {
if (this.embedded) {
this.actionCompleted.emit();
} else {
// Back to the society-wide dividend register. Not '../', which used to be this product's
// own dividend list — that page is gone now that a dividend is declared for every product
// at once, and relative navigation would land on a route that no longer exists.
this.router.navigate([
'/products',
'share-products',
'dividends'
]);
this.router.navigate(['../'], { relativeTo: this.route });
}
});
}
@@ -28,9 +28,15 @@
}
</div>
</div>
<!-- Dividends are declared for the whole society at once, from Share Products → Dividends,
because the board resolves one rate for the year rather than one per product. -->
<div style="display: flex; gap: 10px; flex-shrink: 0">
<button
class="pv-edit-btn"
type="button"
[routerLink]="['../dividends']"
*mifosxHasPermission="'UPDATE_SHAREPRODUCT'"
>
<fa-icon icon="hand-holding-usd"></fa-icon><span>{{ 'labels.buttons.Dividends' | translate }}</span>
</button>
<button class="pv-edit-btn" type="button" [routerLink]="['../edit']" *mifosxHasPermission="'UPDATE_SHAREPRODUCT'">
<fa-icon icon="edit"></fa-icon><span>{{ 'labels.buttons.Edit' | translate }}</span>
</button>
@@ -36,7 +36,7 @@
</div>
<div class="meta-item">
<span class="meta-label">Account No.</span>
<span class="meta-value">{{ accountData.fdAccountNumber || accountData.accountNo }}</span>
<span class="meta-value">{{ accountData.accountNo }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Date of Issue</span>
@@ -49,13 +49,9 @@
This is to certify that
<strong class="highlight-text">{{ accountData.clientName || accountData.groupName }}</strong>
holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number
<strong class="highlight-text">{{ accountData.fdAccountNumber || accountData.accountNo }}</strong>
<strong class="highlight-text">{{ accountData.accountNo }}</strong>
with <strong class="highlight-text">Nexora Co-operative Bank Ltd.</strong>, as per the particulars set out
below.
<ng-container *ngIf="renewalCount">
This account was renewed on maturity {{ renewalCount }} {{ renewalCount === 1 ? 'time' : 'times' }}; the
full renewal history is set out below.
</ng-container>
</p>
<div class="issued-to-details">
@@ -133,44 +129,6 @@
</tbody>
</table>
<!-- Renewal History -- one row per term of this FD, oldest first. Deliberately shows only the
term number, not each term's internal account number: the customer holds one FD under one
number, and listing the per-term account numbers here would read as several deposits. Staff
can still see them on the account view's own renewal-history card. -->
<table class="certificate-details-table renewal-history-table" *ngIf="renewalHistory.length">
<thead>
<tr>
<th colspan="7" class="renewal-history-title">RENEWAL HISTORY</th>
</tr>
<tr>
<th>Term</th>
<th>From</th>
<th>To</th>
<th>Rate</th>
<th>Principal</th>
<th>Interest</th>
<th>Maturity Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let term of renewalHistory" [class.current-term-row]="term.accountid === accountData.id">
<td>{{ term.renewalcycleno === 0 ? 'Original' : 'Renewal ' + term.renewalcycleno }}</td>
<td>{{ term.activatedondate | dateFormat }}</td>
<td>{{ term.maturitydate | dateFormat }}</td>
<td>{{ term.interestrate }}%</td>
<td>{{ term.depositamount | currency: accountData.currency.code : 'symbol-narrow' }}</td>
<td>
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: accountData.currency.code : 'symbol-narrow')
}}
</td>
<td>{{ maturityFor(term) | currency: accountData.currency.code : 'symbol-narrow' }}</td>
</tr>
</tbody>
</table>
<div class="cert-footer">
<div class="seal-container">
<div class="seal-ring">
@@ -250,32 +250,6 @@
}
}
/* Renewal History Table -- lists each earlier term this FD was renewed from */
.renewal-history-table {
th {
padding: 8px 10px;
border: 1px solid #ede8db;
font-size: 11px;
text-align: left;
background-color: #f7f5ee;
color: #7b6f63;
}
.renewal-history-title {
text-align: center;
background-color: #f5f2e9;
color: #0b3c5d;
font-weight: bold;
letter-spacing: 1.5px;
font-size: 12px;
}
.current-term-row {
background-color: #fbfaf5;
font-weight: bold;
}
}
/* Certificate Footer Seal / Signatures */
.cert-footer {
display: flex;
@@ -511,24 +485,6 @@
}
}
.renewal-history-table {
margin-bottom: 12px !important;
th {
padding: 4px 8px !important;
font-size: 8px !important;
}
td {
padding: 4px 8px !important;
font-size: 9px !important;
}
.renewal-history-title {
font-size: 10px !important;
}
}
.cert-footer {
margin-top: 8px !important;
@@ -60,29 +60,12 @@ export class DepositCertificateDialogComponent implements OnInit {
certificateNo = '';
issueDate: Date = new Date();
/**
* Every term of this FD, oldest first, as resolved by the backend -- a renewal opens each term as
* its own account, so the certificate lists them all to show the deposit's full history. Empty for
* an FD that has never been renewed. Keys are lower-cased, arriving from SQL column labels.
*/
renewalHistory: any[] = [];
/**
* How many times the deposit has actually rolled over. {@link renewalHistory} lists every TERM, and the
* first of those is the original deposit rather than a renewal, so the count is one less than the number
* of rows. Stated on a customer-facing certificate, so an off-by-one here misreports the deposit.
*/
get renewalCount(): number {
return Math.max(this.renewalHistory.length - 1, 0);
}
ngOnInit() {
this.accountType = this.data.accountType;
this.accountData = this.data.accountData;
const clientId = this.data.clientId != null ? String(this.data.clientId) : null;
this.certificateNo = `${this.accountType}-CERT-${this.accountData.fdAccountNumber || this.accountData.accountNo}`;
this.renewalHistory = this.accountData?.renewalHistory ?? [];
this.certificateNo = `${this.accountType}-CERT-${this.accountData.accountNo}`;
if (!clientId) {
return;
@@ -117,14 +100,6 @@ export class DepositCertificateDialogComponent implements OnInit {
});
}
/**
* A term's settled maturity amount, or its projection while it is still running. Resolved here rather
* than inline so the choice isn't left to operator precedence between `??` and the currency pipe.
*/
maturityFor(term: any): number | null {
return term?.maturedamount ?? term?.projectedmaturityamount ?? null;
}
formatAddress(addr: any): string {
if (!addr) return '';
return [
+4 -18
View File
@@ -183,13 +183,9 @@ export class FooterComponent implements OnInit, OnDestroy {
*/
getConfigurations(): void {
if (this.authenticationService.isAuthenticated()) {
// Reading global configuration needs the READ_CONFIGURATION permission, which teller-style
// roles (cashier, clerk) do not carry. This runs from the footer on every authenticated
// page, so an unpermitted role was greeted by an "Unauthorized Request" modal at login for
// a check it never asked for. The footer only uses this to decide whether to show a
// business date, so a denied read is not an error here — it degrades to the system date.
this.systemService.getConfigurationByName(SettingsService.businessDateConfigName, true).subscribe({
next: (configurationData: any) => {
this.systemService
.getConfigurationByName(SettingsService.businessDateConfigName)
.subscribe((configurationData: any) => {
this.isBusinessDateEnabled = configurationData.enabled;
this.settingsService.setBusinessDateConfig(configurationData.enabled);
if (this.isBusinessDateEnabled) {
@@ -198,17 +194,7 @@ export class FooterComponent implements OnInit, OnDestroy {
this.getConfigurations();
}, 60000);
}
},
error: () => {
// No visibility of the config (403) or it is unavailable — leave the business date off
// and stop polling, rather than re-checking once a minute for a read this role will
// never be allowed to make. The stored config is deliberately left untouched: a denied
// read yields no value to record, and overwriting it here would let a cashier's session
// clobber what an authorised user established.
this.isBusinessDateEnabled = false;
clearTimeout(this.timer);
}
});
});
} else {
clearTimeout(this.timer);
}
@@ -132,7 +132,6 @@ export class CreateSharesAccountComponent {
charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })),
applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat),
submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat),
resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat),
unitPrice: this.sharesAccountTermsForm.get('unitPrice').value,
dateFormat,
locale
@@ -140,13 +140,9 @@ export class EditShareAccountDetailsComponent {
const payload: any = {
locale,
dateFormat: 'yyyy-MM-dd',
savingsAccountId: terms.savingsAccountId,
allowDividendCalculationForInactiveClients: terms.allowDividendCalculationForInactiveClients
};
// Optional: only sent when a savings account is actually linked, so an unlinked account
// does not post a null the update validator would reject.
if (terms.savingsAccountId) {
payload.savingsAccountId = terms.savingsAccountId;
}
if (terms.minimumActivePeriod) {
payload.minimumActivePeriod = terms.minimumActivePeriod;
}
@@ -138,7 +138,6 @@ export class EditSharesAccountComponent {
charges: this.sharesAccount.charges.map((charge: any) => ({ chargeId: charge.id, amount: charge.amount })),
applicationDate: this.dateUtils.formatDate(this.sharesAccount.applicationDate, dateFormat),
submittedDate: this.dateUtils.formatDate(this.sharesAccount.submittedDate, dateFormat),
resolutionDate: this.dateUtils.formatDate(this.sharesAccount.resolutionDate, dateFormat),
unitPrice: this.sharesAccountTermsForm.get('unitPrice').value,
dateFormat,
locale
@@ -54,24 +54,6 @@
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-48" (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
formControlName="resolutionDate"
required
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
<div class="layout-row align-center gap-2percent margin-t responsive-column">
@@ -86,8 +68,7 @@
!(
sharesAccountDetailsForm.get('productId')?.value &&
sharesAccountDetailsForm.get('submittedDate')?.value &&
sharesAccountDetailsForm.get('resolutionNumber')?.value &&
sharesAccountDetailsForm.get('resolutionDate')?.value
sharesAccountDetailsForm.get('resolutionNumber')?.value
)
"
>
@@ -76,9 +76,7 @@ export class SharesAccountDetailsStepComponent implements OnInit {
this.sharesAccountTemplate.timeline.submittedOnDate &&
new Date(this.sharesAccountTemplate.timeline.submittedOnDate),
externalId: this.sharesAccountTemplate.externalId,
resolutionNumber: this.sharesAccountTemplate.resolutionNumber,
resolutionDate:
this.sharesAccountTemplate.resolutionDate && new Date(this.sharesAccountTemplate.resolutionDate)
resolutionNumber: this.sharesAccountTemplate.resolutionNumber
});
}
}
@@ -104,10 +102,6 @@ export class SharesAccountDetailsStepComponent implements OnInit {
resolutionNumber: [
'',
Validators.required
],
resolutionDate: [
'',
Validators.required
]
});
}
@@ -41,11 +41,6 @@
<span class="flex-60">{{ sharesAccount.resolutionNumber }}</span>
</div>
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Resolution Date' | translate }}</span>
<span class="flex-60">{{ sharesAccount.resolutionDate | dateFormat }}</span>
</div>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
<mat-divider class="flex-fill"></mat-divider>
@@ -76,15 +71,9 @@
<div class="flex-fill">
<span class="flex-40">{{ 'labels.inputs.Default Savings Account' | translate }}</span>
<span class="flex-60">
@if (sharesAccount.savingsAccountId) {
{{
sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo'
}}
} @else {
{{ 'labels.inputs.Unassigned' | translate }}
}
</span>
<span class="flex-60">{{
sharesAccount.savingsAccountId | find: sharesAccountProductTemplate.clientSavingsAccounts : 'id' : 'accountNo'
}}</span>
</div>
@if (sharesAccount.minimumActivePeriod) {
@@ -30,9 +30,6 @@
<span class="flex-48">{{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }}</span>
}
<!-- No "None" option: the server rejects a share account without a linked savings account, and
offering the choice only produced a failure after the whole stepper was filled in. The link
is also where an over-payment at the counter is credited back to the member. -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label>
<mat-select formControlName="savingsAccountId" required>
@@ -45,12 +42,6 @@
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (savingsAccountsData?.length === 0) {
<p class="sats-field-note flex-98">
This client has no active savings account in the share product's currency. Open one first — a share account
cannot be created without it.
</p>
}
<mat-form-field class="flex-48" (click)="applicationDatePicker.open()">
<mat-label>{{ 'labels.inputs.Application Date' | translate }}</mat-label>
@@ -76,15 +67,6 @@
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Minimum Active Period' | translate }}</h4>
<!-- Spelled out because the field name does not say what it decides. This is the holding period
that qualifies a member for a dividend: shares bought fewer days before the period end than
this are skipped entirely by the dividend allocation, and the member is paid nothing on
them. Setting it wrong is silent — nobody sees it until a declaration pays out. -->
<p class="sats-field-note flex-98">
Enter the minimum number of days a member must hold these shares before they qualify for a dividend. Shares held
for fewer days than this are excluded from the dividend calculation and earn nothing for that period.
</p>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label>
<input type="number" matInput formControlName="minimumActivePeriod" />
@@ -106,15 +88,6 @@
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Lock-in Period' | translate }}</h4>
<!-- Sits next to Minimum Active Period and is easily confused with it, so the difference is
stated outright: that one decides whether a dividend is earned, this one decides whether
shares can be sold back. Counted per purchase, not per account — a later purchase carries
its own lock-in from its own date, which is not obvious from a single "Frequency" box. -->
<p class="sats-field-note flex-98">
Enter the minimum number of days a member must wait before they can redeem (sell back) these shares. This is
counted separately from each purchase, and does not affect dividends — for that, use Minimum Active Period above.
</p>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label>
<input type="number" matInput formControlName="lockinPeriodFrequency" />

Some files were not shown because too many files have changed in this diff Show More