Compare commits

..
Author SHA1 Message Date
anshul 0404197c1b feat(investment): interest voucher posting UI, home pending alert and day-end reminder 2026-10-14 15:11:43 +05:30
anshul 8a6becff86 Merge origin/dev-latest into feature/investment 2026-10-14 10:49:37 +05:30
anshulandClaude Opus 5.5 c803e20628 feat(investment): settle purchases from Holdings; broken-period interest to settlement date
- Purchases are saved as Pending Settlement. Holdings show the status and
  a Settle button (from the settlement date), and the GL transaction id
  once settled.
- The transaction log shows "Not posted (pending settlement)" until a
  deal is settled, and dates the purchase on its settlement date.
- Broken-period interest is counted up to the settlement date.
- Accounting Preview title and the Holdings Settlement Date column
  reflect settlement-date posting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:28:19 +05:30
anshulandClaude Opus 5.5 6a04a92113 feat(investment): Daily Script Master, market rate on purchase, broken-period interest as expense
- 09 Daily Script Master now saves to and loads from the backend (sample
  rows removed), with Drop to correct a wrong rate.
- Purchase Entry takes the rate (NAV for a mutual fund) from the newest
  Daily Script Master rate on or before the deal date, shows which rate
  was used, warns when the rate entered is outside the day's low-high,
  and clears the rate when the script has none.
- Broken-period interest paid to the seller is debited to expense GL
  500606 instead of Interest Receivable (RBI UCB Investment Directions,
  para 152, confirmed by Jitendra Sir); Interest Entry labels it so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:27:15 +05:30
anshulandClaude Opus 5.5 88f34f58e3 feat(investment): deal time, interest cycle and charges on Purchase Entry
As per Jitendra Sir's review:
- Deal Time is now a mandatory field next to Deal Date.
- New Interest Cycle section: the two coupon days, last and next coupon,
  next coupon amount and days to it.
- Stamp duty, brokerage and other charges sit on the main form. They are
  expenses on their own GLs (500601/500602/500603), so they are added to
  the settlement amount but no longer to the book value, and each shows
  as its own debit line in the accounting preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:54:33 +05:30
anshul 88c4242692 Merge origin/dev-latest into feature/investment 2026-10-12 10:35:53 +05:30
anshulandClaude Opus 5.5 ca01e9b65b feat(investment): Interest Entry screen with coupon and provision schedule
Replace the dummy interest calculation tab with the real Interest Entry:
pick Type / Script / Deal No and Retrieve the holding's schedule from
the backend - every half-yearly coupon and every 31 Mar / 30 Sep closing
provision up to maturity, with days (30/360), gross interest, amount
already booked, net income and the Dr/Cr entry per line. Display only;
nothing is posted to GL yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:36:01 +05:30
anshulandClaude Opus 5.5 a4e0a22205 feat(investment): wire Bank/Party, Script Master and Purchase Entry to the backend
- Bank / Party Master and Script Master follow the bank's existing CBS
  screens (address, bank type, GL A/c, RBI code, limit, LEI, counterparty
  bank details; SLR type, auto script code, ISIN, interest rate, issue /
  maturity date, auto period, company details) and save to
  /investment/parties and /investment/scripts. GL dropdowns list enabled
  ASSET detail accounts from the chart of accounts.
- Purchase Entry simplified to the mandatory fields; script details,
  settlement date, last interest date, units, book value, premium/discount,
  accrued interest (30/360 up to the deal date), settlement amount and YTM
  are filled automatically; optional fields sit under "More Details";
  an Accounting Preview shows the GL entry. Save / Retrieve / Drop use
  /investment/purchases and the Holdings register lists saved purchases.
- New InvestmentService for the investment API calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:38:10 +05:30
101 changed files with 3802 additions and 9498 deletions
@@ -386,6 +386,23 @@
</div>
</div>
}
<!-- Investment interest vouchers that are due but not posted. A reminder only: unlike the interest
above it never stops the close (Jitendra Sir); the voucher can still be posted afterwards. -->
@if (preview?.investmentInterestPending?.length) {
<div class="bd-warn">
<mat-icon>notifications_active</mat-icon>
<div>
<strong>Reminder: investment interest vouchers are pending.</strong>
@for (t of preview.investmentInterestPending; track t.purchaseId) {
<div>
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
{{ t.intDate }} · ₹{{ (t.lineType === 'COUPON' ? t.grossInterest : t.amount) | number: '1.2-2' }}
</div>
}
Post them under Investment → Govt Security / Bond → 03 Interest Entry. You can still close the day.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
@@ -1,229 +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="cv-tab-content cta-tab">
<!-- ── TITLE BAR ── -->
<div class="cta-action-bar">
<div class="cta-action-bar__left">
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
<div>
<div class="cta-action-bar__title">Cash Transfers</div>
<div class="cta-action-bar__sub">
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
approved.
</div>
</div>
</div>
<div class="cta-counts">
<span class="cta-count"
><strong>{{ all.length }}</strong> waiting</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="cta-filter-bar">
<div class="cta-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="cta-empty">
<mat-icon class="cta-empty__icon">inbox</mat-icon>
<p class="cta-empty__title">No cash transfer waiting</p>
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
</div>
} @else {
<div class="cta-table-wrap">
<table class="cta-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To (Cashier)</th>
<th class="num">Amount</th>
<th>Note</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="cta-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr class="cta-row">
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
<td>{{ r.from_party || '—' }}</td>
<td class="cta-strong">{{ r.to_party || '—' }}</td>
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
<td>{{ r.maker_name || '—' }}</td>
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
<td class="cta-action-cell">
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
<mat-icon>rate_review</mat-icon> Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
<ng-template #reviewDialog>
@if (reviewing) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Review cash transfer</div>
<div class="cta-dlg__sub">
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<div class="cta-total">
<span>Amount to release</span>
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
</div>
<div class="cta-grid">
<div class="cta-cell">
<div class="cta-label">Transfer date</div>
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">From</div>
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">To (cashier)</div>
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">Notes and coins</div>
<div class="cta-value">
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
</div>
</div>
@if (reviewing.note) {
<div class="cta-cell cta-cell--wide">
<div class="cta-label">Note</div>
<div class="cta-value">{{ reviewing.note }}</div>
</div>
}
</div>
<div class="cta-label">Denomination breakdown</div>
<table class="cta-mini">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of linesOf(reviewing); track l.value) {
<tr>
<td>₹{{ l.value }}</td>
<td class="num">{{ l.count }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="num">{{ piecesOf(reviewing) }}</td>
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
<button
type="button"
class="cta-btn cta-btn--reject"
[disabled]="busyId === reviewing.id"
(click)="rejectFromReview(reviewing)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="cta-btn cta-btn--approve"
[disabled]="busyId === reviewing.id"
(click)="approve(reviewing)"
>
<mat-icon>check</mat-icon> Approve
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico">block</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Reject cash transfer</div>
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<label class="cta-label" for="cta-reason">Reason (required)</label>
<textarea
id="cta-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="cta-btn cta-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -1,573 +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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.cta-tab {
padding: 0;
}
.cta-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.cta-counts {
display: flex;
gap: 8px;
}
.cta-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.cta-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.cta-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.cta-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.cta-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.cta-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.cta-table-wrap {
overflow-x: auto;
background: #fff;
}
.cta-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.cta-th-action {
text-align: right;
}
}
.cta-row:hover td {
background: #f8fafc;
}
.cta-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.cta-voucher {
font-weight: 700;
color: #0f4f3c;
}
.cta-strong {
font-weight: 700;
}
.cta-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.cta-none {
color: #cbd5e1;
}
.cta-amount {
font-weight: 700;
}
.cta-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cta-dt {
white-space: nowrap;
color: #475569;
}
.cta-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.cta-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.cta-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.cta-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
max-height: 64vh;
overflow-y: auto;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.cta-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.cta-dlg__ico--ok {
background: #f0fdf4;
color: #1f7a4d;
}
.cta-total {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
.cta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.cta-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
&--wide {
grid-column: 1 / -1;
}
}
.cta-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
.cta-mini {
width: 100%;
margin-top: 4px;
border-collapse: collapse;
font-size: 12px;
th {
padding: 6px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-top: 2px solid #e2e8f0;
border-bottom: 0;
font-weight: 800;
background: #f8fafc;
}
}
@@ -1,190 +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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
interface CashTransferRequest {
id: number;
transfer_date: string | null;
from_party: string | null;
to_party: string | null;
note: string | null;
total_amount: number;
maker_name: string | null;
maker_at: string | null;
denominations: { denominationValue: number; count: number }[];
}
/**
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
* leaves the vault only when it is approved, by someone other than the person who raised it.
*/
@Component({
selector: 'mifosx-cash-transfer-approvals',
templateUrl: './cash-transfer-approvals.component.html',
styleUrls: ['./cash-transfer-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class CashTransferApprovalsComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
private reviewRef: MatDialogRef<unknown> | null = null;
/** The request open in the review popup, with every note and coin it asks for. */
reviewing: CashTransferRequest | null = null;
all: CashTransferRequest[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
searchTerm = '';
rejecting: CashTransferRequest | null = null;
rejectReason = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getCashTransferRequests('PENDING')
.pipe(catchError(() => of([] as CashTransferRequest[])))
.subscribe((rows) => {
this.all = rows as CashTransferRequest[];
this.loading = false;
this.loaded = true;
});
}
get rows(): CashTransferRequest[] {
const term = this.searchTerm.trim().toLowerCase();
if (!term) return this.all;
return this.all.filter((r) => [
r.from_party,
r.to_party,
r.maker_name,
r.note
].some((v) => (v ?? '').toLowerCase().includes(term)));
}
/** One line per denomination asked for, largest first, with what each line adds up to. */
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
return [...(r.denominations ?? [])]
.filter((d) => d.count > 0)
.sort((a, b) => b.denominationValue - a.denominationValue)
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
}
/** How many notes and coins the request asks for in all. */
piecesOf(r: CashTransferRequest): number {
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
}
review(r: CashTransferRequest): void {
this.reviewing = r;
this.reviewRef?.close();
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
}
closeReview(): void {
this.reviewRef?.close();
}
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
rejectFromReview(r: CashTransferRequest): void {
this.reviewRef?.close();
this.startReject(r);
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(r: CashTransferRequest): void {
if (this.busyId) return;
this.busyId = r.id;
this.service.approveCashTransferRequest(r.id).subscribe({
next: (res: any) => {
this.busyId = null;
this.reviewRef?.close();
this.snackBar.open(
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(r: CashTransferRequest): void {
this.rejecting = r;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const id = this.rejecting.id;
this.busyId = id;
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -13,15 +13,7 @@
<h1 class="pg-title">Cash Transfer</h1>
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div>
<div class="ct-head-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
<mat-icon>pending_actions</mat-icon> My Requests
@if (pendingCount) {
<span class="ct-badge">{{ pendingCount }}</span>
}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@@ -163,56 +155,7 @@
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>send</mat-icon> Send for approval
<mat-icon>swap_horiz</mat-icon> Record Transfer
</button>
</div>
</div>
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
<ng-template #requestsDialog>
<div class="ct-dlg">
<header class="ct-dlg__head">
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
<div class="ct-dlg__titles">
<div class="ct-dlg__title">My transfer requests</div>
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
</div>
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="ct-dlg__body">
@if (myRequests.length) {
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>To</th>
<th class="num">Amount</th>
<th>Status</th>
<th>Approved by</th>
</tr>
</thead>
<tbody>
@for (r of myRequests; track r.id) {
<tr>
<td>{{ r.transfer_date || '—' }}</td>
<td>{{ r.to_party || '—' }}</td>
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td>
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
@if (r.rejection_reason) {
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
}
</td>
<td>{{ r.checker_name || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
}
</div>
</div>
</ng-template>
@@ -49,131 +49,3 @@
font-size: 12px;
color: #991b1b;
}
.ct-requests {
padding: 0;
&__title {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__note {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
}
.ct-status {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--PENDING {
background: #fef3c7;
color: #92400e;
}
&--APPROVED {
background: #d1fae5;
color: #065f46;
}
&--REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
.ct-head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ct-badge {
min-width: 18px;
padding: 0 6px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.ct-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
max-height: 60vh;
overflow-y: auto;
}
}
@@ -6,10 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -40,7 +39,6 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
@@ -50,10 +48,6 @@ interface Denom {
export class CashTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
notes: Denom[] = [];
coins: Denom[] = [];
@@ -88,34 +82,7 @@ export class CashTransferComponent implements OnInit {
vaultHasBreakdown = true;
loadingVault = false;
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
myRequests: any[] = [];
loadMyRequests(): void {
this.service
.getCashTransferRequests(undefined, true)
.pipe(catchError(() => of([] as any[])))
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
}
/** How many of this person's requests are still waiting for a manager. */
get pendingCount(): number {
return this.myRequests.filter((r) => r.status === 'PENDING').length;
}
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
openRequests(): void {
this.loadMyRequests();
this.requestsRef?.close();
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
}
closeRequests(): void {
this.requestsRef?.close();
}
ngOnInit(): void {
this.loadMyRequests();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
@@ -278,11 +245,16 @@ export class CashTransferComponent implements OnInit {
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashTransferRequest(payload).subscribe({
next: () => {
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
this.loadMyRequests();
this.service.createCashTransfer(payload).subscribe({
next: (res: any) => {
if (res?.glWarning) {
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
duration: 8000,
panelClass: ['snack-warn']
});
} else {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
}
this.clear();
this.note = '';
this.fromParty = '';
-13
View File
@@ -83,13 +83,6 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'closure-requests',
loadComponent: () =>
@@ -298,12 +291,6 @@ const routes: Routes = [
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'cash-history',
component: CashHistoryComponent,
+2 -15
View File
@@ -47,21 +47,8 @@ export class CashierService {
}
// ── Cash Transfer ──
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
createCashTransferRequest(data: any): Observable<any> {
return this.http.post('/cashtransfer/requests', data);
}
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<any[]>('/cashtransfer/requests', { params });
}
approveCashTransferRequest(id: number): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
}
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
}
getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
@@ -202,7 +202,6 @@
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>Correction</th>
<th>By</th>
<th>Approved By</th>
<th>Time</th>
@@ -232,18 +231,6 @@
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="db-muted">—</span>
}
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@@ -269,7 +256,7 @@
—
}
</td>
<td colspan="6"></td>
<td colspan="5"></td>
</tr>
</tfoot>
</table>
@@ -14,7 +14,6 @@ import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -27,11 +26,6 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
@@ -64,8 +58,7 @@ interface DayBookRow {
imports: [
CommonModule,
FormsModule,
MatIconModule,
VoucherStatusComponent
MatIconModule
]
})
export class DayBookComponent implements OnInit {
@@ -146,7 +146,6 @@
<th class="num">Amount</th>
<th>My part</th>
<th>Status</th>
<th>Correction</th>
<th>Time</th>
</tr>
</thead>
@@ -204,18 +203,6 @@
{{ statusLabel(r.verificationStatus) }}
</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="mw-acct">—</span>
}
</td>
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr>
}
+1 -8
View File
@@ -15,7 +15,6 @@ import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -25,11 +24,6 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
side: VoucherSide | null;
voucherType: string | null;
amount: number | null;
@@ -82,8 +76,7 @@ interface MyWorkReport {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
VoucherStatusComponent
MatDialogModule
]
})
export class MyWorkComponent implements OnInit {
@@ -11,7 +11,6 @@ import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
@@ -19,10 +18,6 @@ export interface VoucherDetailDialogData {
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
branchName: string | null;
@@ -63,8 +58,7 @@ export interface VoucherDetailDialogData {
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
VoucherStatusComponent
MatIconModule
],
template: `
<div class="vd-shell">
@@ -72,15 +66,7 @@ export interface VoucherDetailDialogData {
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<h2>
{{ detail.voucherNo }}
<mifosx-voucher-status
[status]="detail.voucherStatus"
[revokedBy]="detail.revokedByVoucherNo"
[replaces]="detail.replacesVoucherNo"
[note]="detail.correctionNote"
></mifosx-voucher-status>
</h2>
<h2>{{ detail.voucherNo }}</h2>
<p>{{ detail.voucherType || 'Voucher' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
@@ -117,8 +117,6 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -129,16 +127,11 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl" title="Branch">BRANCH</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
@@ -184,7 +177,13 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -267,7 +266,12 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -276,7 +280,6 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="row.dbCr = 'Cr'"
>
CR
@@ -708,81 +711,70 @@
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
Personal Information
</div>
@if (isGlAccount) {
<div class="te-panel__body">
@for (f of glInfo; track f.label) {
<div class="te-field">
<span class="te-label">{{ f.label }}</span
><span class="te-value te-value-wrap" [class.te-mono]="f.mono">{{ f.value }}</span>
</div>
}
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
</div>
} @else {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
</div>
<div class="te-field">
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
</div>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
<div class="te-field">
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
}
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
</div>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
</div>
</div>
<!-- Joint Holders + Nominees -->
@@ -937,78 +929,76 @@
<!-- Other Info + KYC + Account Balances -->
<div class="te-panel">
@if (!isGlAccount) {
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
</div>
<div class="te-field">
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
</div>
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-field">
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
</div>
<div class="te-field">
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
</div>
<div class="te-field">
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
</div>
<div class="te-field">
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
</div>
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
</div>
}
}
</div>
}
}
</div>
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
<div class="te-panel__header te-panel__header--mt">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
@@ -1577,8 +1567,6 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -1589,16 +1577,11 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
@@ -1644,7 +1627,13 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -1715,7 +1704,12 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="isGlCreditOnly(row)"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -1724,7 +1718,6 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
>
CR
@@ -2050,13 +2043,7 @@
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
@if (isClerk) {
<button
class="ws-btn ws-btn--add"
type="button"
[disabled]="selectedRow?.typ === 'Withdrawal Slip'"
(click)="addRow()"
title="Add another transaction row"
>
<button class="ws-btn ws-btn--add" type="button" (click)="addRow()" title="Add another transaction row">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
@@ -1658,8 +1658,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Full account + client data
accountDetails: any = null;
/** Parent (header) GL of the selected GL account, shown in the GL Account Information panel. */
glParent: { glCode?: string; name?: string } | null = null;
clientDetails: any = null;
kycDetails: any = null;
nominees: any[] = [];
@@ -1687,12 +1685,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private searchToken = 0;
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
/** How many characters of the A/C NO. start the automatic account search. */
private static readonly AC_NO_AUTO_SEARCH_MIN_LENGTH = 6;
cashVaultGlId: number | null = null;
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
* entry has to be filed against THIS account, not whichever account the teller typed in, since
* that account is never the one physical notes actually moved through.
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
cashVaultGlCode: string | null = null;
@@ -1877,7 +1873,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
ngOnInit(): void {
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.loadBranches();
this.addRow();
this.loadDenominations();
this.loadOpeningPosition();
@@ -1908,8 +1903,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
)
.subscribe(({ row, index, value }) => {
// Searches on its own once six characters are typed, for clerk and cashier alike.
if (value.trim().length >= TransactionEntryComponent.AC_NO_AUTO_SEARCH_MIN_LENGTH) {
if (value.trim().length >= 4) {
this.selectedRow = row;
this.loadAccountDetails(value.trim(), row.acTyp);
}
@@ -2310,7 +2304,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// anyway, just without the extra click to get there.
const row: TxnRow = {
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: this.defaultBranchId,
br: 1,
trTp: '',
acTyp: 'SD',
acNo: '',
@@ -2329,40 +2323,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.push(row);
}
/**
* A GL row is credit-only (money received into the account) except on the transfer types, which pick
* either side, and on a Withdrawal Slip, which is always a debit: the GL is debited and the cash
* vault credited when cash is paid out against it (a clerk's request, approved, then paid by the cashier).
*/
isGlCreditOnly(row: TxnRow): boolean {
return (
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
row.typ !== 'Withdrawal Slip'
);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
* leaving the branch to the member, so its TYPE is fixed to Payment and cannot be changed.
* Never true for the clerk screen or for rows keyed in by hand.
*/
isTypLocked(row: TxnRow): boolean {
return (
!this.isClerk &&
!!row.tokNo?.trim() &&
!!(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest)
);
}
onTypChange(row: TxnRow): void {
if (this.isTypLocked(row)) {
row.typ = 'Payment';
return;
}
switch (row.typ) {
case 'Withdrawal Slip':
row.dbCr = 'Dr';
@@ -2478,7 +2440,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL';
return row.acTyp === 'GL' && row.dbCr === 'Cr';
}
/**
@@ -2656,47 +2618,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
/**
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
*/
branches: { id: number; label: string }[] = [];
private loadBranches(): void {
const own = this.authService.getCredentials();
const ownBranch = own?.officeId
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
: [];
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
// office, which is the one branch it is offered.
const permissions = own?.permissions ?? [];
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
if (!mayReadOffices) {
this.branches = ownBranch;
return;
}
this.http
.get('/offices')
.pipe(catchError(() => of([])))
.subscribe((list: any) => {
const offices: any[] = Array.isArray(list) ? list : [];
this.branches =
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
});
}
private branchLabel(id: number, name: string | null | undefined): string {
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
}
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
get defaultBranchId(): number {
return this.authService.getCredentials()?.officeId ?? 1;
}
branchNameFor(branchId: number): string {
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
const map: Record<number, string> = {
1: '001 · Main',
2: '002 · M.G. Road',
14: '014 · Station Rd',
27: '027 · Industrial'
};
return map[branchId] || `Branch ${branchId}`;
}
/**
@@ -3989,8 +3918,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD',
GL: 'GL'
RECURRING_DEPOSIT: 'RD'
};
openCustomerSearch(): void {
@@ -4315,17 +4243,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
this.glParent = null;
if (acTyp?.toUpperCase() === 'GL' && acc.parentId) {
this.http
.get<any>(`/glaccounts/${acc.parentId}`)
.pipe(catchError(() => of(null)))
.subscribe((p: any) => {
if (token !== this.searchToken) return;
this.glParent = p ? { glCode: p.glCode, name: p.name } : null;
});
}
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
@@ -4896,40 +4813,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
/** True when the loaded account is a General Ledger account (/glaccounts), which has no client behind it. */
get isGlAccount(): boolean {
return !!this.accountDetails?.glCode && this.selectedRow?.acTyp === 'GL';
}
/** The GL account's own details, shown in place of the client panels. */
get glInfo(): { label: string; value: string; mono?: boolean }[] {
const gl = this.accountDetails;
if (!gl) return [];
const type = gl.type?.value ?? '—';
const nature = [
'ASSET',
'EXPENSE'
].includes(String(type).toUpperCase()) ? 'Debit' : 'Credit';
return [
{ label: 'GL Code', value: gl.glCode ?? '—', mono: true },
{ label: 'GL Name', value: gl.name ?? '—' },
{ label: 'Account Type', value: type },
{ label: 'Usage', value: gl.usage?.value ?? '—' },
{ label: 'Normal Balance', value: nature },
{
label: 'Parent GL',
value: this.glParent?.name
? `${this.glParent.glCode ? this.glParent.glCode.trim() + ' - ' : ''}${this.glParent.name.trim()}`
: gl.parentId != null
? `ID ${gl.parentId}`
: '—'
},
{ label: 'Manual Entries', value: gl.manualEntriesAllowed ? 'Allowed' : 'Not allowed' },
{ label: 'Status', value: gl.disabled ? 'Disabled' : 'Enabled' },
{ label: 'Description', value: gl.description || '—' }
];
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
@@ -5964,14 +5847,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// A GL withdrawal slip is raised by the clerk and paid by the cashier against its token, exactly like a
// savings withdrawal (the clerk branch and the token branch below). Nothing posts from this screen directly:
// manual journal entries are switched off on the server.
if (row.acTyp === 'GL' && !isDeposit && !this.clerkMode && !this.activeWithdrawalRequest) {
this.showAlert('error', 'GL Withdrawal', [
'A GL withdrawal is raised by a clerk and paid by the cashier against its token.',
'Enter the token number of the approved request to pay it.'
]);
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
this.submitGlJournalEntry(row, amount, isDeposit);
return;
}
@@ -6344,34 +6223,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isGl = row.acTyp === 'GL';
// The Entry Type picked for a GL slip has no column of its own on the request, so it rides on the narration.
const entryTypeName = isGl ? this.entryTypeOptions.find((e) => e.id === row.entryTypeCvId)?.name : undefined;
const description = [
row.description,
entryTypeName ? `[Entry Type: ${entryTypeName}]` : ''
]
.filter(Boolean)
.join(' ');
return {
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
accountNo: this.accountDetails.accountNo,
accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: isGl ? null : this.accountDetails.id,
// GL-specific fields: the GL being paid out of and the cash GL it is paid from (null for other types)
glAccountId: isGl ? this.accountDetails.id : null,
contraGlId: isGl ? this.cashVaultGlId : null,
officeId: isGl ? row.br || 1 : null,
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
clientName: isGl
? (this.accountDetails?.name ?? null)
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: description || null,
description: row.description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
tokNo: row.tokNo?.trim() || null,
@@ -6563,6 +6425,106 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.addRow();
}
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
this.saving = false;
return;
}
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedGlId = this.accountDetails.id;
const contraGlId = this.cashVaultGlId;
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
const payload: any = {
officeId: row.br || 1,
transactionDate: txnDate,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
comments: row.description || null,
credits,
debits,
paymentTypeId: row.paymentTypeId
};
// Check if cash payment so we can record denomination after journal entry
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashPayment = !!selectedPt?.isCashPayment;
this.saving = true;
this.http
.post('/journalentries', payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Journal entry failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
// Record denomination so cashier position stays accurate
// Same condition as savings: cash + denom entered + denom matches posted amount
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
const denomBreakdown = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
// instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
denominationBreakdown: denomBreakdown
})
.pipe(catchError(() => of(null)))
.subscribe();
}
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
this.showAlert('success', 'GL Transaction Posted', [
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Transaction ID: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
@@ -1,170 +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="cv-tab-content vca-tab">
<!-- ── TITLE BAR ── -->
<div class="vca-action-bar">
<div class="vca-action-bar__left">
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
<div>
<div class="vca-action-bar__title">Voucher Corrections</div>
<div class="vca-action-bar__sub">
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
moves money.
</div>
</div>
</div>
<div class="vca-counts">
<span class="vca-count"
><strong>{{ unpassCount }}</strong> unpass</span
>
<span class="vca-count"
><strong>{{ correctionCount }}</strong> correction</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="vca-filter-bar">
<div class="vca-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="vca-fb-sep"></span>
<select class="vca-filter-select" [(ngModel)]="kindFilter">
@for (opt of kindOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="vca-empty">
<mat-icon class="vca-empty__icon">inbox</mat-icon>
<p class="vca-empty__title">No voucher correction waiting</p>
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
</div>
} @else {
<div class="vca-table-wrap">
<table class="vca-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Request</th>
<th>Posted To (Wrong)</th>
<th>Correct Account</th>
<th class="num">Amount</th>
<th>Reason</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="vca-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.request.id) {
<tr class="vca-row">
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
<td>
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
</td>
<td>
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
</td>
<td>
@if (r.kind === 'correction') {
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
} @else {
<span class="vca-none">—</span>
}
</td>
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
<td>{{ r.raisedBy || '—' }}</td>
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
<td class="vca-action-cell">
<button
type="button"
class="vca-btn vca-btn--reject"
[disabled]="busyId === r.request.id"
(click)="startReject(r)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="vca-btn vca-btn--approve"
[disabled]="busyId === r.request.id"
(click)="approve(r)"
>
<mat-icon>check</mat-icon>
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="vca-dlg">
<header class="vca-dlg__head">
<mat-icon class="vca-dlg__ico">block</mat-icon>
<div class="vca-dlg__titles">
<div class="vca-dlg__title">
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
</div>
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
</div>
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vca-dlg__body">
<label class="vca-label" for="vca-reason">Reason (required)</label>
<textarea
id="vca-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="vca-dlg__foot">
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="vca-btn vca-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -1,485 +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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.vca-tab {
padding: 0;
}
.vca-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.vca-counts {
display: flex;
gap: 8px;
}
.vca-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.vca-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.vca-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.vca-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.vca-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.vca-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vca-table-wrap {
overflow-x: auto;
background: #fff;
}
.vca-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vca-th-action {
text-align: right;
}
}
.vca-row:hover td {
background: #f8fafc;
}
.vca-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vca-voucher {
font-weight: 700;
color: #0f4f3c;
}
.vca-strong {
font-weight: 700;
}
.vca-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.vca-none {
color: #cbd5e1;
}
.vca-amount {
font-weight: 700;
}
.vca-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vca-dt {
white-space: nowrap;
color: #475569;
}
.vca-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.vca-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.vca-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.vca-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vca-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
@@ -1,192 +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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
type Kind = 'unpass' | 'correction';
type KindFilter = 'ALL' | Kind;
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
interface ApprovalRow {
kind: Kind;
request: VoucherCorrection;
raisedBy: string | null;
raisedAt: string | null;
}
/**
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
*/
@Component({
selector: 'mifosx-voucher-correction-approvals',
templateUrl: './voucher-correction-approvals.component.html',
styleUrls: ['./voucher-correction-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class VoucherCorrectionApprovalsComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
all: ApprovalRow[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
kindFilter: KindFilter = 'ALL';
searchTerm = '';
/** The row being rejected, and the reason typed for it. */
rejecting: ApprovalRow | null = null;
rejectReason = '';
readonly kindOptions: { value: KindFilter; label: string }[] = [
{ value: 'ALL', label: 'All requests' },
{ value: 'unpass', label: 'Unpass requests' },
{ value: 'correction', label: 'Correction entries' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.list('UNPASS_PENDING,CORRECTION_PENDING')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.all = rows.map((r) =>
r.status === 'UNPASS_PENDING'
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
);
this.loading = false;
this.loaded = true;
});
}
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
get rows(): ApprovalRow[] {
const term = this.searchTerm.trim().toLowerCase();
return this.all.filter((r) => {
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
if (!term) return true;
const q = r.request;
return [
q.original_voucher_no,
q.wrong_account_no,
q.wrong_client_name,
q.correct_account_no,
q.correct_client_name,
r.raisedBy
].some((v) => (v ?? '').toLowerCase().includes(term));
});
}
get unpassCount(): number {
return this.all.filter((r) => r.kind === 'unpass').length;
}
get correctionCount(): number {
return this.all.filter((r) => r.kind === 'correction').length;
}
kindLabel(kind: Kind): string {
return kind === 'unpass' ? 'Unpass' : 'Correction';
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(row: ApprovalRow): void {
if (this.busyId) return;
const id = row.request.id;
this.busyId = id;
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
call.subscribe({
next: (res: any) => {
this.busyId = null;
this.snackBar.open(
row.kind === 'unpass'
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(row: ApprovalRow): void {
this.rejecting = row;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const row = this.rejecting;
const id = row.request.id;
this.busyId = id;
const reason = this.rejectReason.trim();
const call =
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
call.subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -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/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
export interface VoucherCorrection {
id: number;
original_voucher_no: string;
original_txn_id: number;
wrong_savings_account_id: number;
wrong_account_no: string;
wrong_client_name: string | null;
amount: number;
txn_date: string | null;
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
reason: string | null;
rejection_reason: string | null;
unpass_by_name: string | null;
unpass_at: string | null;
unpass_approved_by_name: string | null;
correct_account_no: string | null;
correct_client_name: string | null;
correction_by_name: string | null;
correction_at: string | null;
correction_approved_by_name: string | null;
new_voucher_no: string | null;
}
/** A cash deposit voucher of today that can still be unpassed. */
export interface EligibleVoucher {
voucherNo: string;
amount: number;
createdAt: string;
cashier: string;
txnId: number;
savingsAccountId: number;
accountNo: string;
clientName: string | null;
paymentType: string;
}
/** What the server knows about a savings account typed in as the correct one. */
export interface AccountLookup {
id: number;
accountNo: string;
clientId: number | null;
clientName: string | null;
balance: number;
currencyCode: string;
canReceive: boolean;
}
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
@Injectable({ providedIn: 'root' })
export class VoucherCorrectionService {
private http = inject(HttpClient);
private readonly base = '/vouchers/corrections';
eligible(search?: string): Observable<EligibleVoucher[]> {
let params = new HttpParams();
if (search) params = params.set('search', search);
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
}
unpass(voucherNo: string, reason: string): Observable<any> {
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
}
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<VoucherCorrection[]>(this.base, { params });
}
get(id: number): Observable<VoucherCorrection> {
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
}
accountLookup(accountNo: string): Observable<AccountLookup> {
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
}
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
}
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
close(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/close`, { reason });
}
approveUnpass(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
}
rejectUnpass(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
}
approveCorrection(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/approve`, {});
}
rejectCorrection(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
}
/** Ids of the transactions of this account that a completed correction removed. */
voidedTransactions(savingsAccountId: number): Observable<number[]> {
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
}
}
@@ -1,446 +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 · VOUCHER CORRECTION</div>
<h1 class="pg-title">Voucher Correction</h1>
<p class="pg-sub">
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
approval.
</p>
</div>
</header>
<!-- The four steps, so it is always clear where a voucher is. -->
<ol class="vc-steps">
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
<span class="vc-step__no">1</span>
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
</li>
<li class="vc-step">
<span class="vc-step__no">2</span>
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
</li>
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
<span class="vc-step__no">3</span>
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
</li>
<li class="vc-step">
<span class="vc-step__no">4</span>
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
</li>
</ol>
<nav class="vc-tabs">
@if (mayUnpass) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
<mat-icon>receipt_long</mat-icon> Today's Vouchers
</button>
}
@if (mayCorrect) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
@if (unpassed.length) {
<span class="vc-count">{{ unpassed.length }}</span>
}
</button>
}
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
<mat-icon>history</mat-icon> My Requests
</button>
</nav>
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
@if (tab === 'today') {
<div class="vc-toolbar">
<div class="vc-search">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by voucher, account or customer name"
[(ngModel)]="search"
(keyup.enter)="loadVouchers()"
/>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
<mat-icon>search</mat-icon> Search
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Account</th>
<th>Customer</th>
<th>Mode</th>
<th>Cashier</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.voucherNo) {
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
<td>
<span class="pg-chip">{{ v.voucherNo }}</span>
</td>
<td class="vc-mono">{{ v.accountNo }}</td>
<td>{{ v.clientName || '—' }}</td>
<td>{{ v.paymentType }}</td>
<td>{{ v.cashier }}</td>
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--ghost vc-btn-sm"
(click)="pick(v); $event.stopPropagation()"
>
<mat-icon>undo</mat-icon> Unpass
</button>
</td>
</tr>
}
@if (!vouchers.length) {
<tr>
<td colspan="7" class="vc-empty">
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
@if (tab === 'unpassed') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Customer</th>
<th>Reason</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (u of unpassed; track u.id) {
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
<td>
<span class="pg-chip">{{ u.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ u.wrong_account_no }}</td>
<td>{{ u.wrong_client_name || '—' }}</td>
<td class="vc-clip">{{ u.reason }}</td>
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--primary vc-btn-sm"
(click)="open(u); $event.stopPropagation()"
>
<mat-icon>edit</mat-icon> Correct
</button>
</td>
</tr>
}
@if (!unpassed.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
<div>
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── My requests ──────────────────────────────────────────────────── -->
@if (tab === 'mine') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Corrected to</th>
<th class="num">Amount</th>
<th>Status</th>
<th>New voucher</th>
</tr>
</thead>
<tbody>
@for (m of mine; track m.id) {
<tr>
<td>
<span class="pg-chip">{{ m.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ m.wrong_account_no }}</td>
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
<td>
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
@if (m.rejection_reason) {
<div class="vc-note">{{ m.rejection_reason }}</div>
}
</td>
<td>{{ m.new_voucher_no || '—' }}</td>
</tr>
}
@if (!mine.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
<ng-template #unpassDialog>
@if (selectedVoucher) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">undo</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Unpass voucher</div>
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Deposit amount</span>
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
</div>
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Voucher</div>
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Created</div>
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Cashier</div>
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account</div>
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Customer</div>
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account balance now</div>
<div class="vc-value">
@if (voucherAccount) {
₹{{ voucherAccount.balance | number: '1.2-2' }}
} @else {
—
}
</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account status</div>
<div class="vc-value">
@if (voucherAccount) {
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
} @else {
—
}
</div>
</div>
</div>
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
<textarea
id="vc-reason"
class="vc-reason"
rows="2"
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
[(ngModel)]="unpassReason"
></textarea>
<div class="vc-info">
<mat-icon>info</mat-icon>
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
deposit stays on {{ selectedVoucher.accountNo }} till then.
</div>
</div>
<footer class="vc-dlg__foot">
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
<mat-icon>send</mat-icon> Send for approval
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
<ng-template #correctionDialog>
@if (active) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Correction entry</div>
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Amount to move</span>
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
</div>
<!-- Everything but the account number is fixed. -->
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Transaction date</div>
<div class="vc-value">{{ active.txn_date || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">Cash</div>
</div>
<div class="vc-cell">
<div class="vc-label">Unpassed by</div>
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Approved by</div>
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
</div>
<div class="vc-cell vc-cell--wide">
<div class="vc-label">Reason</div>
<div class="vc-value">{{ active.reason }}</div>
</div>
</div>
<div class="vc-compare">
<!-- The account the deposit went to -->
<div class="vc-acct vc-acct--wrong">
<div class="vc-acct__tag">Posted to (wrong)</div>
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
<div class="vc-acct__bal">
Balance:
@if (wrongAccount) {
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
@if (wrongHasBalance) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
} @else {
<span class="vc-bad"
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
>
}
} @else {
—
}
</div>
</div>
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
<!-- The account it should have gone to -->
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
<div class="vc-acct__tag">Correct account</div>
<input
id="vc-correct"
class="vc-acct__input vc-mono"
type="text"
placeholder="Type the account number"
[ngModel]="correctAccountNo"
(ngModelChange)="onAccountInput($event)"
(blur)="lookup()"
/>
@if (lookingUp) {
<div class="vc-acct__bal">Looking up…</div>
}
@if (correctAccount) {
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
<div class="vc-acct__bal">
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
@if (correctOk) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
}
</div>
}
@if (lookupError) {
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
}
</div>
</div>
@if (wrongAccount && !wrongHasBalance) {
<div class="vc-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The customer has already used this money,</strong> so it cannot be taken back from
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
</div>
</div>
}
</div>
<footer class="vc-dlg__foot">
<button
type="button"
class="pg-btn pg-btn--ghost vc-dlg__close-req"
[disabled]="sending"
(click)="closeRequest()"
>
<mat-icon>block</mat-icon> Close request
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
<mat-icon>check</mat-icon> Save &amp; send for approval
</button>
</footer>
</div>
}
</ng-template>
@@ -1,599 +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/.
*/
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
.pg {
padding: 14px 20px;
gap: 12px;
}
.pg-title {
font-size: 20px;
margin: 2px 0;
}
.pg-sub {
font-size: 12px;
}
.pg-btn {
height: 30px;
padding: 0 12px;
font-size: 12px;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-table {
font-size: 12px;
th {
padding: 8px 12px;
font-size: 10px;
}
td {
padding: 7px 12px;
}
}
// ── Steps ───────────────────────────────────────────────────────────────────
.vc-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.vc-step {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
font-size: 12px;
color: #64748b;
&__no {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
color: #475569;
}
&--on {
border-color: #1f7a4d;
background: #f0fdf4;
color: #0f4f3c;
.vc-step__no {
background: #1f7a4d;
color: #fff;
}
}
}
// ── Tabs ────────────────────────────────────────────────────────────────────
.vc-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.vc-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
margin-bottom: -1px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 600;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
color: #0f4f3c;
}
&--on {
color: #0f4f3c;
border-bottom-color: #1f7a4d;
}
}
.vc-count {
padding: 0 7px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
}
// ── Search ──────────────────────────────────────────────────────────────────
.vc-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vc-search {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 280px;
max-width: 420px;
height: 30px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
&:focus-within {
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #94a3b8;
}
input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 12px;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vc-table {
tbody tr {
cursor: pointer;
}
tr.vc-row--on td {
background: #f0fdf4;
&:first-child {
box-shadow: inset 3px 0 0 #1f7a4d;
}
}
.vc-col-action {
width: 1%;
text-align: right;
white-space: nowrap;
}
.vc-clip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pg-chip {
font-size: 11px;
padding: 2px 8px;
}
}
.vc-btn-sm {
height: 26px;
padding: 0 10px;
font-size: 11px;
}
.vc-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vc-empty {
padding: 24px 12px;
text-align: center;
color: #94a3b8;
font-size: 12px;
mat-icon {
display: block;
margin: 0 auto 4px;
font-size: 28px;
height: 28px;
width: 28px;
opacity: 0.6;
}
}
// ── Popups ──────────────────────────────────────────────────────────────────
.vc-dlg {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
background: #e2e8f0;
}
}
&__body {
padding: 12px 14px;
overflow-y: auto;
}
&__amount {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vc-warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 11px;
mat-icon {
flex: 0 0 18px;
height: 18px;
width: 18px;
font-size: 18px;
}
}
.vc-dlg__close-req {
margin-right: auto;
color: #b45309;
}
.vc-label {
display: block;
margin-bottom: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.vc-reason {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
.vc-info {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 7px 10px;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
mat-icon {
flex: 0 0 16px;
height: 16px;
width: 16px;
font-size: 16px;
}
}
.vc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.vc-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.vc-cell--wide {
grid-column: 1 / -1;
}
.vc-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
// ── Wrong → Correct ─────────────────────────────────────────────────────────
.vc-compare {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 8px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.vc-arrow {
align-self: center;
color: #94a3b8;
@media (width <= 640px) {
transform: rotate(90deg);
justify-self: center;
}
}
.vc-acct {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
&--wrong {
border-color: #fecaca;
background: #fef2f2;
}
&--ok {
border-color: #a7f3d0;
background: #ecfdf5;
}
&__tag {
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
&__no {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
&__name {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
&__bal {
margin-top: 3px;
font-size: 11px;
color: #475569;
}
&__err {
margin-top: 6px;
}
}
.vc-ok,
.vc-bad {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 700;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.vc-ok {
color: #059669;
}
.vc-bad {
color: #dc2626;
}
.vc-note {
margin-top: 3px;
font-size: 11px;
color: #64748b;
}
// ── Status ──────────────────────────────────────────────────────────────────
.vc-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--UNPASS_PENDING,
&--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
&--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
&--COMPLETED {
background: #d1fae5;
color: #065f46;
}
&--CLOSED {
background: #f1f5f9;
color: #475569;
}
&--UNPASS_REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
@@ -1,307 +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, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
AccountLookup,
EligibleVoucher,
VoucherCorrection,
VoucherCorrectionService
} from '../voucher-correction.service';
type Tab = 'today' | 'unpassed' | 'mine';
/**
* Cashier side of correcting a cash deposit that went to the wrong account.
*
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
* (needs a manager approval again; only then do the accounts change).
*/
@Component({
selector: 'mifosx-voucher-correction',
templateUrl: './voucher-correction.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-correction.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule
]
})
export class VoucherCorrectionComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private auth = inject(AuthenticationService);
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
readonly mayCorrect = this.auth.isCashierUser();
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
// Today vouchers
search = '';
vouchers: EligibleVoucher[] = [];
loadingVouchers = false;
selectedVoucher: EligibleVoucher | null = null;
/** The account the voucher was posted to, for the balance and status shown in the popup. */
voucherAccount: AccountLookup | null = null;
unpassReason = '';
sending = false;
// Unpassed vouchers waiting for the correct account
unpassed: VoucherCorrection[] = [];
loadingUnpassed = false;
active: VoucherCorrection | null = null;
wrongAccount: AccountLookup | null = null;
correctAccountNo = '';
correctAccount: AccountLookup | null = null;
lookupError = '';
lookingUp = false;
// The cashier own requests
mine: VoucherCorrection[] = [];
loadingMine = false;
readonly statusLabel: Record<string, string> = {
UNPASS_PENDING: 'Unpass awaiting approval',
UNPASSED: 'Unpassed — enter correct account',
UNPASS_REJECTED: 'Unpass rejected',
CORRECTION_PENDING: 'Correction awaiting approval',
COMPLETED: 'Corrected',
CLOSED: 'Closed — cashier deposits separately'
};
ngOnInit(): void {
if (this.mayUnpass) this.loadVouchers();
if (this.mayCorrect) this.loadUnpassed();
if (this.tab === 'mine') this.loadMine();
}
setTab(tab: Tab): void {
this.tab = tab;
if (tab === 'today') this.loadVouchers();
if (tab === 'unpassed') this.loadUnpassed();
if (tab === 'mine') this.loadMine();
}
// ── Today vouchers ─────────────────────────────────────────────────────────
loadVouchers(): void {
this.loadingVouchers = true;
this.service
.eligible(this.search.trim())
.pipe(catchError(() => of([] as EligibleVoucher[])))
.subscribe((rows) => {
this.vouchers = rows;
this.loadingVouchers = false;
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
this.selectedVoucher = null;
}
});
}
pick(v: EligibleVoucher): void {
this.selectedVoucher = v;
this.unpassReason = '';
this.voucherAccount = null;
this.service
.accountLookup(v.accountNo)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.voucherAccount = a));
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
}
closeUnpass(): void {
this.dialogRef?.close();
}
get canUnpass(): boolean {
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
}
unpass(): void {
if (!this.selectedVoucher || !this.canUnpass) return;
this.sending = true;
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
duration: 4000
});
this.dialogRef?.close();
this.unpassReason = '';
this.loadVouchers();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not unpass the voucher');
}
});
}
// ── Unpassed vouchers: enter the correct account ───────────────────────────
loadUnpassed(): void {
this.loadingUnpassed = true;
this.service
.list('UNPASSED')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.unpassed = rows;
this.loadingUnpassed = false;
});
}
open(row: VoucherCorrection): void {
this.active = row;
this.correctAccountNo = '';
this.correctAccount = null;
this.lookupError = '';
this.wrongAccount = null;
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
this.service
.accountLookup(row.wrong_account_no)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.wrongAccount = a));
this.showDialog(this.correctionDialog, () => (this.active = null));
}
closeActive(): void {
this.dialogRef?.close();
}
onAccountInput(value: string): void {
this.correctAccountNo = value;
this.correctAccount = null;
this.lookupError = '';
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
if (value.trim().length >= 6) this.lookup();
}
lookup(): void {
const accountNo = this.correctAccountNo.trim();
if (!accountNo || this.lookingUp) return;
this.lookingUp = true;
this.service.accountLookup(accountNo).subscribe({
next: (a) => {
this.lookingUp = false;
// A late answer for a number no longer typed is dropped.
if (accountNo !== this.correctAccountNo.trim()) return;
this.correctAccount = a;
if (this.active && a.id === this.active.wrong_savings_account_id) {
this.lookupError = 'This is the account the deposit was already posted to.';
} else if (!a.canReceive) {
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
}
},
error: (err) => {
this.lookingUp = false;
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
}
});
}
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
get wrongHasBalance(): boolean {
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
}
get correctOk(): boolean {
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
}
get canSave(): boolean {
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
}
save(): void {
if (!this.active || !this.canSave) return;
this.sending = true;
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not save the correction');
}
});
}
/**
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
* puts the amount into the right account from their own pocket as a fresh deposit instead.
*/
closeRequest(): void {
if (!this.active || this.sending) return;
const reason = this.wrongHasBalance
? 'Closed by the cashier'
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
this.sending = true;
this.service.close(this.active.id, reason).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not close the request');
}
});
}
// ── My requests ────────────────────────────────────────────────────────────
loadMine(): void {
this.loadingMine = true;
this.service
.list(undefined, true)
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.mine = rows;
this.loadingMine = false;
});
}
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
this.dialogRef?.close();
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(onClose);
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 6000 });
}
}
@@ -169,7 +169,6 @@
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>Correction</th>
<th>User</th>
<!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries
@@ -218,18 +217,6 @@
</td>
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<td>
<mifosx-voucher-status
[status]="row.voucherStatus"
[revokedBy]="row.revokedByVoucherNo"
[replaces]="row.replacesVoucherNo"
[note]="row.correctionNote"
></mifosx-voucher-status>
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
<span class="vr-active">—</span>
}
</td>
<td>{{ row.username || '—' }}</td>
@if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
@@ -92,8 +92,3 @@
text-transform: uppercase;
color: #7c3aed;
}
.vr-active {
font-size: 11px;
color: #94a3b8;
}
@@ -14,7 +14,6 @@ import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which side of the register a screen shows.
@@ -27,11 +26,6 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
@@ -88,8 +82,7 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
MatIconModule,
VoucherStatusComponent
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
@@ -1,137 +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, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
/** Where a voucher stands after a voucher correction. */
export type VoucherStatus =
| 'ACTIVE'
| 'REVOKED'
| 'UNPASS_PENDING'
| 'UNPASSED'
| 'CORRECTION_PENDING'
| 'CORRECTED'
| 'CLOSED'
| null
| undefined;
/**
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
*/
@Component({
selector: 'mifosx-voucher-status',
standalone: true,
imports: [CommonModule],
template: `
@if (label) {
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
@if (note) {
<div class="vs-note">{{ note }}</div>
}
}
`,
styles: [
`
.vs {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
vertical-align: middle;
}
.vs-note {
margin-top: 2px;
max-width: 280px;
font-size: 11px;
line-height: 1.3;
color: #64748b;
}
.vs--REVOKED {
background: #fee2e2;
color: #991b1b;
}
.vs--UNPASS_PENDING,
.vs--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
.vs--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
.vs--CLOSED {
background: #f1f5f9;
color: #475569;
}
.vs--CORRECTED {
background: #d1fae5;
color: #065f46;
}
`
]
})
export class VoucherStatusComponent {
@Input() status: VoucherStatus;
/** The voucher that replaced this one, when it is revoked. */
@Input() revokedBy: string | null | undefined;
/** The voucher this one replaced, when it is the corrected one. */
@Input() replaces: string | null | undefined;
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
@Input() note: string | null | undefined;
get label(): string {
switch (this.status) {
case 'REVOKED':
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
case 'UNPASS_PENDING':
return 'Unpass pending';
case 'UNPASSED':
return 'Unpassed';
case 'CORRECTION_PENDING':
return 'Correction pending';
case 'CLOSED':
return 'Closed — cashier deposits separately';
case 'CORRECTED':
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
default:
return '';
}
}
get hint(): string {
switch (this.status) {
case 'REVOKED':
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
case 'UNPASS_PENDING':
return 'A manager has not yet approved unpassing this voucher.';
case 'UNPASSED':
return 'Unpassed: waiting for the cashier to enter the correct account.';
case 'CORRECTION_PENDING':
return 'The correct account is entered; waiting for a manager to approve.';
case 'CLOSED':
return 'The correction was closed by the cashier; the voucher stays as it was.';
case 'CORRECTED':
return 'This voucher was raised by a correction and replaces the earlier one.';
default:
return '';
}
}
}
-18
View File
@@ -131,14 +131,6 @@
</svg>
My Closure Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/voucher-correction')"
[routerLink]="['/clerk/voucher-correction']"
>
<fa-icon icon="undo"></fa-icon>
Voucher Correction
</a>
@if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap">
<a
@@ -295,13 +287,6 @@
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
<fa-icon icon="user-check"></fa-icon> My Vouchers
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-correction']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="undo"></fa-icon> Voucher Correction
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/received-report']"
@@ -1179,9 +1164,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-correction']">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> Voucher Correction
</button>
</div>
<!-- Transfers & Settlement -->
+17
View File
@@ -47,6 +47,23 @@
</div>
</div>
<!-- Pending investment interest vouchers (coupon / provision due now or within two days) -->
@if (pendingInvestmentInterest.length) {
<a class="investment-interest-alert" routerLink="/investment/bonds" [queryParams]="{ tab: 'interest' }">
<span class="iia-icon">🔔</span>
<span class="iia-text">
<strong>{{ pendingInvestmentInterest.length }} investment interest voucher(s) pending</strong>
@for (t of pendingInvestmentInterest; track t.purchaseId) {
<span class="iia-item">
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
{{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }}
</span>
}
</span>
<span class="iia-action">Open Interest Entry →</span>
</a>
}
<!-- Quick Action Cards -->
<div class="quick-actions">
<a class="qa-card" [routerLink]="['/dashboard']" #buttonDashboard>
+36
View File
@@ -424,3 +424,39 @@ $accent: #0ea5e9;
grid-template-columns: 1fr;
}
}
.investment-interest-alert {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 12px 0;
padding: 12px 16px;
border: 1px solid #f5c26b;
border-left: 4px solid #e08e0b;
border-radius: 8px;
background: #fff8e6;
color: #5c3d00;
text-decoration: none;
.iia-icon {
font-size: 20px;
line-height: 1.2;
}
.iia-text {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
}
.iia-item {
font-size: 13px;
}
.iia-action {
align-self: center;
font-weight: 600;
white-space: nowrap;
}
}
+14
View File
@@ -26,6 +26,7 @@ import { PopoverService } from '../configuration-wizard/popover/popover.service'
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
import { SettingsService } from 'app/settings/settings.service';
import { DscService } from 'app/users/dsc/dsc.service';
import { InvestmentInterestDueDto, InvestmentService } from 'app/investment/investment.service';
/** Custom Components */
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
@@ -109,6 +110,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
this.tenant = this.tenantIdentifier();
this.setFilteredActivities();
this.loadDscStatus();
this.loadPendingInvestmentInterest();
if (!this.authenticationService.hasDialogBeenShown()) {
this.dialog.open(WarningDialogComponent, {
disableClose: false,
@@ -118,6 +120,18 @@ export class HomeComponent implements OnInit, AfterViewInit {
}
}
/** Investment interest lines due now or within two days, shown on the home screen so nobody has to look for them. */
pendingInvestmentInterest: InvestmentInterestDueDto[] = [];
private readonly investmentService = inject(InvestmentService);
/** Silent when the user cannot see investments or nothing is due — the strip simply does not render. */
private loadPendingInvestmentInterest(): void {
this.investmentService.getDueInterest().subscribe({
next: (due) => (this.pendingInvestmentInterest = due ?? []),
error: () => (this.pendingInvestmentInterest = [])
});
}
/**
* Surface a small "DSC verified" badge in the welcome banner when the
* signed-in user has at least one ACTIVE DSC certificate. Silent on
@@ -137,11 +137,12 @@
<th class="num">Units</th>
<th class="num">Face Value</th>
<th class="num">Rate</th>
<th>Purchase Date</th>
<th>Settlement Date</th>
<th>Maturity Date</th>
<th class="num col-in">Accrued Interest</th>
<th class="num col-net">Maturity Value</th>
<th>Status</th>
<th>Settlement</th>
</tr>
</thead>
<tbody>
@@ -160,12 +161,23 @@
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
<td>
<span [class]="statusClass(h)">{{ h.status }}</span>
<span [class]="statusClass(h)">{{
h.status === 'PENDING_SETTLEMENT' ? 'PENDING SETTLEMENT' : h.status
}}</span>
</td>
<td>
@if (h.status === 'PENDING_SETTLEMENT') {
<button type="button" class="pg-btn pg-btn--primary" (click)="settleDeal(h)">
Settle ({{ h.settlementDate }})
</button>
} @else if (h.glTransactionId) {
<span class="pg-chip" title="Journal entry transaction">GL {{ h.glTransactionId }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="11" class="pg-empty">No {{ instrumentLabel(bondType) }} holdings yet.</td>
<td colspan="12" class="pg-empty">No {{ instrumentLabel(bondType) }} holdings yet.</td>
</tr>
}
</tbody>
@@ -176,8 +188,9 @@
<!-- ===================== 01 PURCHASE ENTRY ===================== -->
@if (activeTab === 'purchase') {
<div class="pg-card">
<!-- 1. Deal details — the only fields the dealer has to fill -->
<div class="pos-section-label">
Investment - Govt.Security/Bond Entry ({{ instrumentLabel(purchaseForm.bondType) }})
Purchase Entry — {{ instrumentLabel(purchaseForm.bondType) }} (fields marked * are mandatory)
</div>
<div class="inv-form-grid">
<label class="inv-form-field">
@@ -188,17 +201,6 @@
}
</select>
</label>
<label class="inv-form-field"
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
/></label>
<label class="inv-form-field"
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Deal Time (24 hr, HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
/></label>
<label class="inv-form-field"><span>* ISIN</span><input type="text" [(ngModel)]="purchaseForm.isin" /></label>
<label class="inv-form-field">
<span>* Script Code</span>
<select [(ngModel)]="purchaseForm.scriptCode" (ngModelChange)="onScriptChange()">
@@ -208,167 +210,50 @@
}
</select>
</label>
<label class="inv-form-field">
<span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode">
<option value="">-- Select --</option>
@for (p of activePartyOptions; track p.partyCode) {
<option [value]="p.partyCode">{{ p.partyCode }} — {{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Cert No</span><input type="text" [(ngModel)]="purchaseForm.certNo"
><span>* Deal Date</span
><mifosx-date-ddmmyyyy
[(ngModel)]="purchaseForm.dealDate"
(ngModelChange)="onDealDateChange()"
></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Deal Time (HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
/></label>
<label class="inv-form-field">
<span>Settlement Type</span>
<span>* Settlement Type</span>
<select [(ngModel)]="purchaseForm.settlementType">
<option value="T+0">T+0</option>
<option value="T+1">T+1</option>
<option value="T+2">T+2</option>
</select>
</label>
<label class="inv-form-field"
><span>Settlement Dt</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode">
<option value="">-- Select --</option>
@for (p of partyOptions; track p.partyCode) {
<option [value]="p.partyName">{{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
<option [value]="s.subTypeName">{{ s.subTypeName }}</option>
}
</select>
</label>
@if (isMutualFund) {
<label class="inv-form-field">
<span>* Product Name</span>
<select [(ngModel)]="purchaseForm.productName">
<option value="">-- Select --</option>
@for (p of mfProductOptions; track p.productCode) {
<option [value]="p.productName">{{ p.productName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Holding Days</span><input type="number" [(ngModel)]="purchaseForm.holdingDays" min="0"
><span>* Consider Amt (₹)</span><input type="number" [(ngModel)]="purchaseForm.considerAmt" min="0"
/></label>
<label class="inv-form-field"
><span>* Consider Amt</span><input type="number" [(ngModel)]="purchaseForm.considerAmt" min="0"
/></label>
<label class="inv-form-field"
><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0"
/></label>
<label class="inv-form-field"
><span>NAV</span><input type="number" [(ngModel)]="purchaseForm.nav" min="0" step="0.0001"
/></label>
<label class="inv-form-field"
><span>Stamp Duty</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field"
><span>StampDuty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc"
><span>* NAV</span><input type="number" [(ngModel)]="purchaseForm.nav" min="0" step="0.0001"
/></label>
} @else {
<label class="inv-form-field"
><span>Face Value</span><input type="number" [(ngModel)]="purchaseForm.faceValue" min="0"
><span>* Face Value (₹)</span><input type="number" [(ngModel)]="purchaseForm.faceValue" min="0"
/></label>
<label class="inv-form-field"
><span>Purchase Rate (per 100)</span
><span>* Purchase Rate (per ₹100)</span
><input type="number" [(ngModel)]="purchaseForm.purchaseRate" min="0" step="0.0001"
/></label>
<label class="inv-form-field"
><span>Unit Val</span><input type="text" [value]="purchaseUnitVal" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="purchaseBookValue | number: '1.2-2'" readonly
/></label>
<label class="inv-form-field"
><span>Premium Amt.</span><input type="number" [(ngModel)]="purchaseForm.premiumAmt"
/></label>
}
<label class="inv-form-field"
><span>* No Unit</span><input type="text" [value]="purchaseUnits" readonly
/></label>
<label class="inv-form-field"
><span>* CR. To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
/></label>
<label class="inv-form-field"
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc"
/></label>
<label class="inv-form-field"
><span>Brokerage</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field">
<span>Broker Nm.</span>
<select [(ngModel)]="purchaseForm.brokerName">
<option value="">-- Select --</option>
@for (b of brokerOptions; track b.brokerCode) {
<option [value]="b.brokerCode">{{ b.brokerCode }} — {{ b.brokerName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Trade No.</span><input type="text" [(ngModel)]="purchaseForm.tradeNo"
/></label>
<label class="inv-form-field"
><span>Order No.</span><input type="text" [(ngModel)]="purchaseForm.orderNo"
/></label>
<label class="inv-form-field">
<span>Recomm. Authority</span>
<select [(ngModel)]="purchaseForm.recommAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sanction Authority</span>
<select [(ngModel)]="purchaseForm.sanctionAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label>
<label class="inv-form-field"
><span>Period(Month)</span><input type="number" [(ngModel)]="purchaseForm.periodMonth" min="0" max="11"
/></label>
<label class="inv-form-field"
><span>Period(Days)</span><input type="number" [(ngModel)]="purchaseForm.periodDays" min="0"
/></label>
<label class="inv-form-field"
><span>Desc Inv</span><input type="text" [(ngModel)]="purchaseForm.descInv"
/></label>
<label class="inv-form-field"
><span>Folio No</span><input type="text" [(ngModel)]="purchaseForm.folioNo"
/></label>
<label class="inv-form-field"
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label>
<label class="inv-form-field"
><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
/></label>
<label class="inv-form-field"
><span>From Dist</span><input type="text" [(ngModel)]="purchaseForm.fromDist"
/></label>
<label class="inv-form-field"
><span>To Dist</span><input type="text" [(ngModel)]="purchaseForm.toDist"
/></label>
<label class="inv-form-field"
><span>Redum/Mat Dt</span
><input type="text" [(ngModel)]="purchaseForm.redumMatDt" placeholder="DD-MM-YYYY (blank = use period)"
/></label>
<label class="inv-form-field">
<span>* Category</span>
<select [(ngModel)]="purchaseForm.category">
@@ -380,32 +265,258 @@
</label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Int Rate</span><input type="number" [(ngModel)]="purchaseForm.intRate" min="0" step="0.01"
><span>* Last Interest Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
></label>
}
<label class="inv-form-field">
<span>* CR. To A/c (Paid From)</span>
<select [(ngModel)]="purchaseForm.crToAc">
@for (g of settlementBankOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Stamp Duty (₹)</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field"
><span>Brokerage (₹)</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field"
><span>Other Charges (₹)</span><input type="number" [(ngModel)]="purchaseForm.otherCharges" min="0"
/></label>
</div>
@if (purchaseMarketRate) {
<p class="pg-sub" style="padding: 0 16px 8px">
Market rate (Daily Script Master) as on {{ purchaseMarketRate.rateDate }}: Close
{{ purchaseMarketRate.closeRate | number: '1.2-4' }}
@if (purchaseMarketRate.lowRate && purchaseMarketRate.highRate) {
(Low {{ purchaseMarketRate.lowRate | number: '1.2-4' }} – High
{{ purchaseMarketRate.highRate | number: '1.2-4' }})
}
@if (purchaseMarketRate.rateDate !== purchaseForm.dealDate) {
— no rate entered for the deal date, the latest earlier rate is used.
}
</p>
@if (purchaseRateOutOfRange) {
<p class="pg-sub" style="padding: 0 16px 8px; color: #b42318; font-weight: 600">
Warning: the rate entered is outside the day's market range.
</p>
}
} @else if (purchaseMarketRate === null) {
<p class="pg-sub" style="padding: 0 16px 8px; color: #b42318">
No rate in the Daily Script Master for this script — enter the day's rate there first.
</p>
}
<!-- 2. Calculated — filled by the system, read-only -->
<div class="pos-section-label">Calculated by System</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal No</span><input type="text" [value]="purchaseForm.dealNo" readonly
/></label>
<label class="inv-form-field"
><span>Cert No</span><input type="text" [value]="purchaseForm.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Settlement Date</span><input type="text" [value]="purchaseSettlementDate" readonly
/></label>
<label class="inv-form-field"
><span>ISIN</span><input type="text" [value]="purchaseForm.isin || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Sub Type</span><input type="text" [value]="purchaseForm.subType || '—'" readonly
/></label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Int Rate %</span><input type="text" [value]="purchaseForm.intRate ?? '—'" readonly
/></label>
<label class="inv-form-field"
><span>YTM (computed)</span><input type="text" [value]="purchaseYtm" readonly
><span>Issue Date</span><input type="text" [value]="purchaseForm.issueDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Maturity Date</span><input type="text" [value]="purchaseMaturityDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Period (Y / M / D)</span
><input
type="text"
[value]="purchaseForm.periodYear + ' / ' + purchaseForm.periodMonth + ' / ' + purchaseForm.periodDays"
readonly
/></label>
}
<label class="inv-form-field"
><span>No. of Units</span><input type="text" [value]="purchaseUnits | number: '1.0-4'" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (purchaseBookValue | number: '1.2-2')" readonly
/></label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Premium (+) / Discount (−)</span
><input type="text" [value]="'₹' + (purchasePremiumDiscount | number: '1.0-0')" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest ({{ purchaseAccruedDays }} days)</span
><input type="text" [value]="'₹' + (purchaseAccruedInterest | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"><span>YTM %</span><input type="text" [value]="purchaseYtm" readonly /></label>
}
<label class="inv-form-field"
><span>Charges (expense)</span
><input type="text" [value]="'₹' + (purchaseTotalCharges | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Total Settlement Amount</span
><input type="text" [value]="'₹' + (purchaseSettlementAmount | number: '1.2-2')" readonly
/></label>
</div>
@if (!isMutualFund && purchaseCouponDates) {
<div class="pos-section-label">Interest Cycle</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Interest Dates (half-yearly)</span><input type="text" [value]="purchaseCouponDates" readonly
/></label>
<label class="inv-form-field"
><span>Last Coupon</span><input type="text" [value]="purchaseForm.lastIntDt || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Next Coupon</span><input type="text" [value]="purchaseNextCouponDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Next Coupon Amount</span
><input type="text" [value]="'₹' + (purchaseNextCouponAmount | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Days to Next Coupon</span><input type="text" [value]="purchaseDaysToNextCoupon" readonly
/></label>
</div>
}
<!-- 3. Accounting preview -->
<div class="pos-section-label">Accounting Preview (posted on Settle, dated the settlement date)</div>
@if (purchaseAccountingPreview.length) {
<table class="pg-table">
<thead>
<tr>
<th>Dr / Cr</th>
<th>GL Code</th>
<th>GL Name</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of purchaseAccountingPreview; track l.side + l.glCode) {
<tr>
<td>{{ l.side }}</td>
<td>{{ l.glCode }}</td>
<td>{{ l.glName || '—' }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="pg-sub">Select a script, enter the amount and CR. To A/c to see the entry.</p>
}
<!-- 4. More details — optional, collapsed by default -->
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="showMoreDetails = !showMoreDetails">
{{ showMoreDetails ? '▾ Hide More Details' : '▸ More Details (optional)' }}
</button>
</div>
@if (showMoreDetails) {
<div class="pos-section-label">More Details (optional)</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Int Type</span>
<select [(ngModel)]="purchaseForm.intType">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
<option [value]="s.subTypeName">{{ s.subTypeName }}</option>
}
</select>
</label>
@if (isMutualFund) {
<label class="inv-form-field"
><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0"
/></label>
<label class="inv-form-field"
><span>Stamp Duty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc"
/></label>
<label class="inv-form-field"
><span>Holding Days</span><input type="number" [(ngModel)]="purchaseForm.holdingDays" min="0"
/></label>
} @else {
<label class="inv-form-field">
<span>Int Type</span>
<select [(ngModel)]="purchaseForm.intType">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>SGL with Bank</span>
<select [(ngModel)]="purchaseForm.sglWithBank">
<option value="">-- Select --</option>
@for (s of sglBankOptions; track s.sglCode) {
<option [value]="s.bankName">{{ s.bankName }} — {{ s.branch }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>TBill GL A/c</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc"
/></label>
}
<label class="inv-form-field">
<span>Broker</span>
<select [(ngModel)]="purchaseForm.brokerName">
<option value="">-- Select --</option>
@for (b of brokerOptions; track b.brokerCode) {
<option [value]="b.brokerCode">{{ b.brokerCode }} — {{ b.brokerName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Accrued Interest (computed)</span
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
><span>Trade No.</span><input type="text" [(ngModel)]="purchaseForm.tradeNo"
/></label>
<label class="inv-form-field"
><span>Settlement Amt of Pur. (computed)</span
><input type="text" [value]="purchaseSettlementAmount | number: '1.4-4'" readonly
><span>Order No.</span><input type="text" [(ngModel)]="purchaseForm.orderNo"
/></label>
<label class="inv-form-field">
<span>Recomm. Authority</span>
<select [(ngModel)]="purchaseForm.recommAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sanction Authority</span>
<select [(ngModel)]="purchaseForm.sanctionAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Meet Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
/></label>
<label class="inv-form-field">
<span>Deal Type</span>
@@ -423,6 +534,80 @@
}
</select>
</label>
<label class="inv-form-field">
<span>Payment Mode</span>
<select [(ngModel)]="purchaseForm.paymentMode">
<option value="">-- Select --</option>
@for (p of paymentModeOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Delivery Mode</span>
<select [(ngModel)]="purchaseForm.deliveryMode">
<option value="">-- Select --</option>
@for (d of deliveryModeOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>LIEN</span>
<select [(ngModel)]="purchaseForm.lien">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
@if (purchaseForm.lien === 'YES') {
<label class="inv-form-field"
><span>LIEN Amount</span><input type="number" [(ngModel)]="purchaseForm.lienAmount"
/></label>
<label class="inv-form-field">
<span>LIEN Reason</span>
<select [(ngModel)]="purchaseForm.lienReason">
<option value="">-- Select --</option>
@for (l of lienReasonOptions; track l.reasonCode) {
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
}
</select>
</label>
}
<label class="inv-form-field"
><span>Folio No</span><input type="text" [(ngModel)]="purchaseForm.folioNo"
/></label>
<label class="inv-form-field"
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label>
<label class="inv-form-field"
><span>Register Address</span><input type="text" [(ngModel)]="purchaseForm.registerAdd"
/></label>
<label class="inv-form-field"
><span>Register City</span><input type="text" [(ngModel)]="purchaseForm.regiCity"
/></label>
<label class="inv-form-field"
><span>Register Pin</span><input type="text" [(ngModel)]="purchaseForm.regiPin"
/></label>
<label class="inv-form-field"
><span>Issue Date (override)</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Redum / Mat Date (override)</span
><input type="text" [(ngModel)]="purchaseForm.redumMatDt" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Desc Inv</span><input type="text" [(ngModel)]="purchaseForm.descInv"
/></label>
<label class="inv-form-field"
><span>From Dist</span><input type="text" [(ngModel)]="purchaseForm.fromDist"
/></label>
<label class="inv-form-field"
><span>To Dist</span><input type="text" [(ngModel)]="purchaseForm.toDist"
/></label>
<label class="inv-form-field">
<span>Mark CBLO</span>
<select [(ngModel)]="purchaseForm.markCblo">
@@ -433,14 +618,6 @@
<label class="inv-form-field"
><span>Amortize Amt.</span><input type="number" [(ngModel)]="purchaseForm.amortizeAmt"
/></label>
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Corp. Bond</span>
<select [(ngModel)]="purchaseForm.corpBond">
@@ -448,76 +625,19 @@
<option value="YES">YES</option>
</select>
</label>
<label class="inv-form-field">
<span>SGL with Bank</span>
<select [(ngModel)]="purchaseForm.sglWithBank">
<option value="">-- Select --</option>
@for (s of sglBankOptions; track s.sglCode) {
<option [value]="s.bankName">{{ s.bankName }} — {{ s.branch }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Register Add</span><input type="text" [(ngModel)]="purchaseForm.registerAdd"
><span>Remarks</span><input type="text" [(ngModel)]="purchaseForm.remarks"
/></label>
<label class="inv-form-field"
><span>Regi City</span><input type="text" [(ngModel)]="purchaseForm.regiCity"
><span>Description</span><input type="text" [(ngModel)]="purchaseForm.description"
/></label>
<label class="inv-form-field"
><span>Regi Pin</span><input type="text" [(ngModel)]="purchaseForm.regiPin"
/></label>
}
<label class="inv-form-field">
<span>LIEN</span>
<select [(ngModel)]="purchaseForm.lien">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
@if (purchaseForm.lien === 'YES') {
<label class="inv-form-field"
><span>LIEN Amount</span><input type="number" [(ngModel)]="purchaseForm.lienAmount"
/></label>
<label class="inv-form-field">
<span>LIEN Reason</span>
<select [(ngModel)]="purchaseForm.lienReason">
<option value="">-- Select --</option>
@for (l of lienReasonOptions; track l.reasonCode) {
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
}
</select>
</label>
}
<label class="inv-form-field">
<span>Payment Mode</span>
<select [(ngModel)]="purchaseForm.paymentMode">
<option value="">--Select--</option>
@for (p of paymentModeOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Delivery Mode</span>
<select [(ngModel)]="purchaseForm.deliveryMode">
<option value="">--Select--</option>
@for (d of deliveryModeOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Remarks</span><input type="text" [(ngModel)]="purchaseForm.remarks"
/></label>
<label class="inv-form-field"
><span>Description</span><input type="text" [(ngModel)]="purchaseForm.description"
/></label>
<label class="inv-form-field"
><span>Maturity / Redemption Value (computed)</span
><input type="text" [value]="'₹' + (purchaseMaturityAmount | number: '1.2-2')" readonly
/></label>
</div>
</div>
}
<div class="inv-form-actions">
<label class="inv-form-field"
><span>Retrieve Cert No</span><input type="text" placeholder="e.g. 30" [(ngModel)]="retrieveCertNo"
/></label>
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="retrievePurchase()">Retrieve</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropPurchase()">Drop</button>
@@ -569,20 +689,47 @@
</div>
}
<!-- ===================== 03 INTEREST ENTRY / CALCULATION ===================== -->
<!-- ===================== 03 INTEREST ENTRY ===================== -->
@if (activeTab === 'interest') {
<div class="pg-card">
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
<div class="pos-section-label">Interest Entry — coupon and 31 Mar / 30 Sep provision schedule</div>
@if (intDueTasks.length) {
<div class="pos-section-label">🔔 Interest entries due ({{ intDueTasks.length }})</div>
<table class="pg-table">
<thead>
<tr>
<th>Deal No</th>
<th>Script</th>
<th>Type</th>
<th>Int Date</th>
<th class="num">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (t of intDueTasks; track t.purchaseId) {
<tr>
<td>
<span class="pg-chip">{{ t.dealNo }}</span>
</td>
<td>{{ t.scriptCode }} — {{ t.scriptDesc }}</td>
<td>{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }}</td>
<td>{{ t.intDate }}</td>
<td class="num">₹{{ (t.lineType === 'COUPON' ? t.grossInterest : t.amount) | number: '1.2-2' }}</td>
<td>{{ t.overdue ? 'Overdue — post now' : t.postable ? 'Due today' : 'Due on ' + t.intDate }}</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="openDueTask(t)">Open</button>
</td>
</tr>
}
</tbody>
</table>
}
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Type</span>
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
<select [(ngModel)]="intType" (ngModelChange)="onIntFilterChange()">
<option value="">-- All Types --</option>
@for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
@@ -591,67 +738,182 @@
</label>
<label class="inv-form-field">
<span>Script</span>
<select [(ngModel)]="intCalcScript">
<select [(ngModel)]="intScript" (ngModelChange)="onIntFilterChange()">
<option value="">-- All Scripts --</option>
@for (s of scriptOptions; track s.scriptCode) {
@for (s of intScriptOptions; track s.scriptCode) {
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>Deal No</span><input type="text" [(ngModel)]="intCalcDealNo" /></label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="intCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
<span>* Deal No</span>
<select [(ngModel)]="intPurchaseId" (ngModelChange)="intSchedule = null">
<option [ngValue]="null">-- Select --</option>
@for (h of intDealOptions; track h.id) {
<option [ngValue]="h.id">{{ h.dealNo }} — {{ h.scriptCode }} (Cert {{ h.certNo }})</option>
}
</select>
</label>
<label class="inv-form-field"
><span>As On Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intAsOnDate"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button>
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="intLoading"
(click)="retrieveInterestSchedule()"
>
{{ intLoading ? 'Retrieving…' : 'Retrieve' }}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
</div>
@if (intCalcExcludedNpi.length > 0) {
@if (intSchedule; as sc) {
<div class="pos-section-label">Purchase Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal No / Cert No</span><input type="text" [value]="sc.dealNo + ' / ' + sc.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Script</span><input type="text" [value]="sc.scriptCode + ' — ' + sc.scriptDesc" readonly
/></label>
<label class="inv-form-field"
><span>Face Value Held</span><input type="text" [value]="'₹' + (sc.faceValue | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Interest Rate</span><input type="text" [value]="sc.intRate + '%'" readonly
/></label>
<label class="inv-form-field"
><span>Coupon per Half-Year</span
><input type="text" [value]="'₹' + (sc.couponPerHalfYear | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Deal Date</span><input type="text" [value]="sc.dealDate" readonly
/></label>
<label class="inv-form-field"
><span>Last Interest Date</span><input type="text" [value]="sc.lastIntDate" readonly
/></label>
<label class="inv-form-field"
><span>Maturity Date</span><input type="text" [value]="sc.maturityDate" readonly
/></label>
<label class="inv-form-field"
><span>Broken Period Interest Paid (expense 500606)</span
><input type="text" [value]="'₹' + (sc.purchasedAccruedInterest | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (sc.bookValue | number: '1.2-2')" readonly
/></label>
</div>
<div class="pos-section-label">
Interest Schedule ({{ intVisibleRows.length }} of {{ sc.rows.length }} lines) — due up to {{ sc.asOnDate }}:
₹{{ intDueTotal | number: '1.2-2' }}
</div>
<p class="pg-sub" style="padding: 0 16px">
Days are 30/360. A provision books the interest earned since the last coupon; the coupon then clears what is
already booked. Post each line with Generate Voucher on or after its date, in date order — the JV voucher is
passed as it is raised.
</p>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Coupon received into (Bank)</span>
<select [(ngModel)]="intBankGlCode">
@for (g of settlementBankOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Schedule</span>
<button type="button" class="pg-btn pg-btn--ghost" (click)="intShowFullSchedule = !intShowFullSchedule">
{{ intShowFullSchedule ? 'Show up to next year' : 'Show full schedule to maturity' }}
</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Type</th>
<th>Int Date</th>
<th>From</th>
<th>To</th>
<th class="num">Days</th>
<th class="num">Int %</th>
<th class="num">Gross Interest</th>
<th class="num">Already Booked</th>
<th class="num col-in">Amount</th>
<th>Accounting</th>
<th>Voucher</th>
</tr>
</thead>
<tbody>
@for (r of intVisibleRows; track r.type + r.intDate) {
<tr [style.opacity]="r.due ? 1 : 0.6">
<td>
<span class="pg-chip">{{ r.type === 'COUPON' ? 'Coupon' : 'Provision' }}</span>
@if (r.due) {
<span class="pg-chip">Due</span>
}
</td>
<td>{{ r.intDate }}</td>
<td>{{ r.fromDate }}</td>
<td>{{ r.toDate }}</td>
<td class="num">{{ r.days }}</td>
<td class="num">{{ sc.intRate }}</td>
<td class="num">₹{{ r.grossInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ r.alreadyBooked | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.2-2' }}</td>
<td style="white-space: nowrap; font-size: 12px">
@if (r.type === 'COUPON') {
<div>Dr Bank ₹{{ r.grossInterest | number: '1.0-0' }}</div>
@if (r.alreadyBooked > 0) {
<div>
Cr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.alreadyBooked | number: '1.0-0' }}
</div>
}
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
} @else {
<div>Dr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.amount | number: '1.0-0' }}</div>
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
}
</td>
<td style="white-space: nowrap">
@if (r.posted) {
<span class="pg-chip" [title]="'GL ' + r.glTransactionId">Posted · {{ r.voucherNo }}</span>
} @else if (r.postable) {
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="intPosting"
(click)="generateVoucher(r)"
>
Generate Voucher
</button>
} @else if (r.dueSoon) {
<span class="pg-chip">🔔 Due {{ r.intDate }}</span>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="11" class="pg-empty">
No interest lines — check the rate, last interest date and maturity date.
</td>
</tr>
}
</tbody>
</table>
<p class="pg-sub" style="padding: 8px 16px 12px">
Interest income GL: {{ sc.interestGlCode }} — {{ sc.interestGlName }} · Interest receivable GL:
{{ interestReceivableGl.glCode }}.
</p>
} @else {
<p class="pg-sub" style="padding: 0 16px 12px">
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
Select a Deal No and press Retrieve to see its interest schedule.
</p>
}
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Type</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest to Post</th>
</tr>
</thead>
<tbody>
@for (h of intCalcTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ interestAccrued(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No holdings match the selected filters.</td>
</tr>
}
</tbody>
</table>
</div>
}
@@ -1100,7 +1362,7 @@
<td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : t.glName || '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr>
} @empty {
@@ -6,16 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
@@ -31,16 +27,26 @@ import {
BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
INVESTMENT_GL_BY_TYPE,
INVESTMENT_GL_LOOKUP,
INTEREST_RECEIVABLE_GL,
BROKEN_PERIOD_INTEREST_GL,
STAMP_DUTY_GL,
BROKERAGE_GL,
OTHER_CHARGES_GL,
SETTLEMENT_BANK_GL_OPTIONS,
periodBetween,
parseDDMMYYYY,
days360,
lastHalfYearlyCouponDate,
nextHalfYearlyCouponDate,
couponDatesLabel,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InterestFreq,
INSTRUMENT_LABELS,
accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity,
maturityAmount,
addPeriod,
@@ -49,6 +55,16 @@ import {
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentDailyRateDto,
InvestmentInterestDueDto,
InvestmentInterestRowDto,
InvestmentInterestScheduleDto,
InvestmentPurchaseDto,
InvestmentService,
apiErrorMessage,
toHolding
} from '../investment.service';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
type BondTab =
@@ -129,6 +145,14 @@ interface BondPurchaseForm {
holdingDays: number | null;
stampDuty: number | null;
stampDutyAc: string;
otherCharges: number | null;
}
interface AccountingPreviewLine {
side: 'Dr' | 'Cr';
glCode: string;
glName: string;
amount: number;
}
interface BondSellForm {
@@ -182,23 +206,75 @@ interface BondSellForm {
DateDdmmyyyyPickerComponent
]
})
export class BondInvestmentComponent {
export class BondInvestmentComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
private readonly route = inject(ActivatedRoute);
/** Script and party dropdowns offer the masters saved in the backend. */
ngOnInit(): void {
// The home-screen interest alert links here with ?tab=interest.
if (this.route.snapshot.queryParamMap.get('tab') === 'interest') {
this.activeTab = 'interest';
}
this.investmentService.syncSharedMasters().subscribe({
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`)
});
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) =>
(this.glIdByCode = new Map(
gls.map((g) => [
g.glCode,
g.id
])
)),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.loadPurchases();
this.loadDueInterest();
}
/** GL id for a GL code — the purchase API takes ids, the form works with codes. */
private glIdByCode = new Map<string, number>();
/** The Holdings register and transaction log are the purchases saved in the backend. */
private loadPurchases(): void {
this.investmentService.getPurchases().subscribe({
next: (purchases) => {
this.holdings = purchases.map(toHolding);
this.txns = purchases.map((d) => this.purchaseTxn(d));
this.purchaseForm = this.blankPurchaseForm();
},
error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`)
});
}
/** A purchase reaches the GL only when it is settled, dated the settlement date. */
private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
const posted = !!d.glTransactionId;
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.settlementDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: posted ? d.investmentGlCode : '',
glName: posted ? `${d.investmentGlName} (txn ${d.glTransactionId})` : 'Not posted (pending settlement)'
};
}
activeTab: BondTab = 'holdings';
/** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */
showMoreDetails = false;
/** Cert No typed in the Retrieve box (Cert No itself is system-generated and read-only). */
retrieveCertNo = '';
bondType: BondType = 'GOVT_SECURITY';
asOnDate = todayDDMMYYYY();
holdings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS,
...MUTUAL_FUND_HOLDINGS
];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType));
/** Loaded from the backend (saved purchases) in ngOnInit. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
// Master-driven dropdown sources
readonly scriptOptions = SCRIPT_MASTER;
@@ -224,23 +300,30 @@ export class BondInvestmentComponent {
purchaseForm: BondPurchaseForm = this.blankPurchaseForm();
sellForm: BondSellForm = this.blankSellForm();
readonly settlementBankOptions = SETTLEMENT_BANK_GL_OPTIONS;
// Redemption Entry
redeemCertNo = '';
redeemDate = this.asOnDate;
redeemRemarks = '';
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
intCalcToDate = this.asOnDate;
intCalcType: BondType | '' = '';
intCalcScript = '';
intCalcDealNo = '';
intCalcAll = false;
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
// 03 Interest Entry (legacy: Type / Script / Deal No + Retrieve) — the backend works out every
// half-yearly coupon and 31 Mar / 30 Sep closing provision of the holding. Display only:
// nothing is posted to GL until the posting rules are approved.
intType: BondType | '' = '';
intScript = '';
intPurchaseId: number | null = null;
intAsOnDate = this.asOnDate;
intSchedule: InvestmentInterestScheduleDto | null = null;
intLoading = false;
/** Interest lines of all holdings due now or within two days — what Jitendra Sir asked to be reminded of. */
intDueTasks: InvestmentInterestDueDto[] = [];
/** By default the schedule shows only lines up to the end of next year (and anything already posted). */
intShowFullSchedule = false;
/** Bank a coupon is received into; defaults to the purchase's paying bank. */
intBankGlCode = '';
intPosting = false;
readonly interestReceivableGl = INTEREST_RECEIVABLE_GL;
// Write-off
writeoffCertNo = '';
@@ -305,20 +388,38 @@ export class BondInvestmentComponent {
return INSTRUMENT_LABELS[type];
}
/** Preview of the deal number the backend will assign: FY prefix (e.g. 202627) + running number. */
private nextDealNo(): string {
return `2026${String(27000039 + this.holdings.length).padStart(9, '0')}`;
const d = parseDDMMYYYY(this.asOnDate);
const fyStart = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
const prefix = `${fyStart}${String((fyStart + 1) % 100).padStart(2, '0')}`;
const nums = (this.holdings ?? [])
.map((h) => h.dealNo)
.filter((n): n is string => !!n && n.startsWith(prefix))
.map((n) => parseInt(n.slice(prefix.length), 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(6, '0')}`;
}
/** Certificate No is system-generated, as in the existing CBS (greyed there). */
/** Preview of the certificate number the backend will assign (running number). */
private nextCertNo(): string {
const nums = (this.holdings ?? []).map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n));
return String((nums.length ? Math.max(...nums) : 0) + 1);
}
private blankPurchaseForm(): BondPurchaseForm {
const type = this.bondType ?? 'GOVT_SECURITY';
this.showMoreDetails = false;
this.retrieveCertNo = '';
this.purchaseMarketRate = undefined;
return {
bondType: type,
dealNo: this.nextDealNo(),
dealDate: this.asOnDate,
dealTime: '',
dealTime: new Date().toTimeString().slice(0, 8),
isin: '',
scriptCode: '',
certNo: '',
certNo: this.nextCertNo(),
settlementType: 'T+0',
settlementDt: this.asOnDate,
faceValue: null,
@@ -326,7 +427,7 @@ export class BondInvestmentComponent {
partyCode: '',
subType: type === 'GOVT_SECURITY' ? 'SLR' : type === 'NON_SLR' ? 'NON SLR' : 'CORPORATE BOND',
tbillGlAc: '',
crToAc: '310070',
crToAc: SETTLEMENT_BANK_GL_OPTIONS[0]?.glCode ?? '',
premiumAmt: null,
brokerage: null,
brokerName: '',
@@ -374,7 +475,8 @@ export class BondInvestmentComponent {
nav: null,
holdingDays: null,
stampDuty: null,
stampDutyAc: ''
stampDutyAc: '',
otherCharges: null
};
}
@@ -444,14 +546,41 @@ export class BondInvestmentComponent {
return this.isMutualFund ? this.purchaseForm.nav || 0 : 100;
}
/** Book Value — what the investment is carried at, i.e. amount actually paid. */
/** Book Value — the price paid for the security. Charges are expenses and stay out of it (Jitendra Sir). */
get purchaseBookValue(): number {
const f = this.purchaseForm;
if (this.isMutualFund) {
return this.purchaseBaseAmount + (f.stampDuty || 0) + (f.brokerage || 0);
return this.purchaseBaseAmount;
}
const rate = f.purchaseRate || 100;
return Math.round((this.purchaseBaseAmount * rate) / 100) + (f.brokerage || 0);
return Math.round((this.purchaseBaseAmount * rate) / 100);
}
/** Stamp duty + brokerage + other charges — paid with the settlement, booked to expense GLs. */
get purchaseTotalCharges(): number {
const f = this.purchaseForm;
return (f.stampDuty || 0) + (f.brokerage || 0) + (f.otherCharges || 0);
}
// Interest cycle of the security being bought: its two coupon days and the next coupon after the deal.
get purchaseCouponDates(): string {
return this.purchaseMaturityDate ? couponDatesLabel(this.purchaseMaturityDate) : '';
}
get purchaseNextCouponDate(): string {
const f = this.purchaseForm;
return this.purchaseMaturityDate && f.dealDate
? nextHalfYearlyCouponDate(this.purchaseMaturityDate, f.dealDate)
: '';
}
get purchaseNextCouponAmount(): number {
const f = this.purchaseForm;
return f.intRate ? Math.round((this.purchaseBaseAmount * f.intRate) / 200) : 0;
}
get purchaseDaysToNextCoupon(): number {
return this.purchaseNextCouponDate ? days360(this.purchaseForm.dealDate, this.purchaseNextCouponDate) : 0;
}
get purchaseYtm(): number {
@@ -462,19 +591,47 @@ export class BondInvestmentComponent {
return approxYtm(f.intRate, f.purchaseRate || 100, yearsBetween(f.issueDate, this.purchaseMaturityDate));
}
/** Accrued Interest at purchase — interest earned by the seller since the last coupon date. */
/** Broken-period interest owed to the seller from the last coupon date up to the settlement date, 30/360. */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
const yrs = Math.max(0, yearsBetween(f.lastIntDt, f.dealDate));
return Math.round(this.purchaseBaseAmount * (f.intRate / 100) * yrs);
return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * this.purchaseAccruedDays) / 360);
}
/** Settlement Amt of Purchase — book value plus accrued interest payable to the seller. */
get purchaseAccruedDays(): number {
const f = this.purchaseForm;
// Up to the settlement date, when the bond leaves the seller (RBI para 83; Jitendra Sir).
return f.lastIntDt && this.purchaseSettlementDate ? days360(f.lastIntDt, this.purchaseSettlementDate) : 0;
}
/** Premium (+) or discount (−) paid over face value. */
get purchasePremiumDiscount(): number {
const f = this.purchaseForm;
if (this.isMutualFund || !f.faceValue || !f.purchaseRate) {
return 0;
}
return Math.round((f.faceValue * (f.purchaseRate - 100)) / 100);
}
/** Re-suggests the last coupon date when the deal date changes. */
onDealDateChange(): void {
this.suggestLastIntDate();
this.loadMarketRate();
}
private suggestLastIntDate(): void {
const f = this.purchaseForm;
const s = this.purchaseScript;
if (!this.isMutualFund && s?.maturityDate && f.dealDate) {
f.lastIntDt = lastHalfYearlyCouponDate(s.maturityDate, f.dealDate) || f.lastIntDt;
}
}
/** Settlement Amt of Purchase — book value, accrued interest payable to the seller and the charges. */
get purchaseSettlementAmount(): number {
return this.purchaseBookValue + this.purchaseAccruedInterest;
return this.purchaseBookValue + this.purchaseAccruedInterest + this.purchaseTotalCharges;
}
get purchaseMaturityAmount(): number {
@@ -504,69 +661,264 @@ export class BondInvestmentComponent {
});
}
/** Fills ISIN + interest rate when a script is picked, mirroring the legacy form's lookup. */
get purchaseScript(): (typeof SCRIPT_MASTER)[number] | undefined {
return this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
}
/** Picking a script pulls its master details into the deal, as the existing CBS does. */
onScriptChange(): void {
const s = this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
if (s) {
this.purchaseForm.isin = s.isin;
const s = this.purchaseScript;
if (!s) {
return;
}
const f = this.purchaseForm;
f.isin = s.isin;
if (s.couponRate != null) {
f.intRate = s.couponRate;
}
if (s.issueDate) {
f.issueDate = s.issueDate;
}
if (s.maturityDate) {
f.redumMatDt = s.maturityDate;
const p = periodBetween(s.issueDate, s.maturityDate);
f.periodYear = p.years;
f.periodMonth = p.months;
f.periodDays = p.days;
}
if (s.slrType) {
f.subType = s.slrType === 'NON_SLR' ? 'NON SLR' : 'SLR';
}
if (this.isMutualFund) {
f.productName = s.scriptName;
}
this.suggestLastIntDate();
this.loadMarketRate();
}
/**
* Market rate of the script for the deal date from the Daily Script Master (newest rate on or before it). It fills
* the purchase rate (NAV for a mutual fund); the dealer may still change it. undefined = not looked up yet,
* null = no rate entered for the script.
*/
purchaseMarketRate: InvestmentDailyRateDto | null | undefined;
private loadMarketRate(): void {
const s = this.purchaseScript;
const f = this.purchaseForm;
if (!s?.id || !f.dealDate) {
this.purchaseMarketRate = undefined;
return;
}
this.investmentService.getLatestDailyRate(s.id, f.dealDate).subscribe({
next: (rate) => {
this.purchaseMarketRate = rate;
// No market rate: clear the field rather than leave another script's rate in it.
if (this.isMutualFund) {
f.nav = rate ? rate.closeRate : null;
} else {
f.purchaseRate = rate ? rate.closeRate : null;
}
},
error: (err) => this.toast(`Could not read the market rate: ${apiErrorMessage(err)}`)
});
}
/** True when the rate entered is outside the day's low–high in the Daily Script Master. */
get purchaseRateOutOfRange(): boolean {
const m = this.purchaseMarketRate;
const f = this.purchaseForm;
const rate = this.isMutualFund ? f.nav : f.purchaseRate;
if (!m || rate == null) {
return false;
}
return (m.lowRate != null && rate < m.lowRate) || (m.highRate != null && rate > m.highRate);
}
/** Settlement Date = Deal Date + T+n days (greyed / auto in the existing CBS). */
get purchaseSettlementDate(): string {
const f = this.purchaseForm;
if (!f.dealDate) {
return '';
}
const n = parseInt(f.settlementType.replace('T+', ''), 10) || 0;
return addPeriod(f.dealDate, 0, 0, n);
}
/** Parties offered for a new deal — inactive parties are hidden. */
get activePartyOptions(): typeof PARTY_MASTER {
return this.partyOptions.filter((p) => p.status !== 'INACTIVE');
}
get purchaseInvestmentGlCode(): string {
return this.purchaseScript?.glCode || INVESTMENT_GL_BY_TYPE[this.purchaseForm.bondType];
}
glNameOf(glCode: string): string {
return INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ?? '';
}
/** The journal entry this purchase will post once the checker approves it. */
get purchaseAccountingPreview(): AccountingPreviewLine[] {
const book = this.purchaseBookValue;
const accrued = this.purchaseAccruedInterest;
const total = this.purchaseSettlementAmount;
const crGl = this.purchaseForm.crToAc;
if (!book || !crGl) {
return [];
}
const invGl = this.purchaseInvestmentGlCode;
const lines: AccountingPreviewLine[] = [
{ side: 'Dr', glCode: invGl, glName: this.glNameOf(invGl), amount: book }];
// Broken-period interest paid to the seller is an expense (RBI para 152, confirmed by Jitendra Sir).
if (accrued > 0) {
lines.push({
side: 'Dr',
glCode: BROKEN_PERIOD_INTEREST_GL.glCode,
glName: BROKEN_PERIOD_INTEREST_GL.glName,
amount: accrued
});
}
const f = this.purchaseForm;
for (const [
gl,
amount
] of [
[
STAMP_DUTY_GL,
f.stampDuty
],
[
BROKERAGE_GL,
f.brokerage
],
[
OTHER_CHARGES_GL,
f.otherCharges
]
] as const) {
if (amount && amount > 0) {
lines.push({ side: 'Dr', glCode: gl.glCode, glName: gl.glName, amount });
}
}
lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total });
return lines;
}
savePurchase(): void {
const f = this.purchaseForm;
if (!f.certNo || !this.purchaseBaseAmount || !f.partyCode) {
this.toast('Please fill Cert No, Face Value / Consider Amt and Party Code.');
const missing: string[] = [];
if (!f.scriptCode) missing.push('Script Code');
if (!f.partyCode) missing.push('Party Code');
if (!f.dealDate) missing.push('Deal Date');
if (!f.dealTime) missing.push('Deal Time');
if (this.isMutualFund) {
if (!f.considerAmt) missing.push('Consider Amt');
if (!f.nav) missing.push('NAV');
} else {
if (!f.faceValue) missing.push('Face Value');
if (!f.purchaseRate) missing.push('Purchase Rate');
if (!f.lastIntDt) missing.push('Last Interest Date');
}
if (!f.category) missing.push('Category');
if (!f.crToAc) missing.push('CR. To A/c');
if (missing.length) {
this.toast(`Please fill: ${missing.join(', ')}.`);
return;
}
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: f.bondType,
scriptCode: f.scriptCode || f.isin || f.productName,
scriptName: this.isMutualFund
? f.productName
: this.scriptOptions.find((s) => s.scriptCode === f.scriptCode)?.scriptName || f.scriptCode || f.isin,
party: f.partyCode,
subType: f.subType,
faceValue: this.purchaseBaseAmount,
purchaseRate: this.isMutualFund ? f.nav || 0 : f.purchaseRate || 100,
units: this.purchaseUnits,
purchaseDate: f.issueDate,
maturityDate: this.purchaseMaturityDate || f.issueDate,
interestRate: f.intRate || 0,
interestFreq: this.isMutualFund ? 'CUMULATIVE' : f.intType,
glCode:
'31' +
(f.bondType === 'GOVT_SECURITY'
? '0020'
: f.bondType === 'NON_SLR'
? '0030'
: f.bondType === 'BOND'
? '0040'
: '0050'),
glName: `INVESTMENT IN ${INSTRUMENT_LABELS[f.bondType].toUpperCase()}`,
crToAc: f.crToAc,
brokerCode: f.brokerName,
status: 'ACTIVE'
const script = this.purchaseScript;
const party = this.partyOptions.find((p) => p.partyCode === f.partyCode);
const crGlAccountId = this.glIdByCode.get(f.crToAc);
if (!script?.id || !party?.id) {
this.toast('Script and Party must be saved in Investment Masters first.');
return;
}
if (!crGlAccountId) {
this.toast(`GL ${f.crToAc} was not found in the chart of accounts.`);
return;
}
const body = {
bondType: f.bondType,
scriptId: script.id,
partyId: party.id,
dealDate: f.dealDate,
dealTime: f.dealTime || '',
settlementType: f.settlementType,
...(this.isMutualFund
? { considerAmt: f.considerAmt, nav: f.nav }
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
stampDuty: f.stampDuty ?? null,
brokerage: f.brokerage ?? null,
otherCharges: f.otherCharges ?? null,
category: f.category,
crGlAccountId,
subType: f.subType || '',
otherDetails: this.purchaseOtherDetails(),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: f.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${f.dealNo}`,
txnDate: f.dealDate,
amount: this.purchaseSettlementAmount,
particulars: `Purchase Deal ${f.dealNo} - ${holding.scriptName} - ${holding.party}${f.category ? ' [' + f.category + ']' : ''}`
this.investmentService.createPurchase(body).subscribe({
next: (res) => {
this.toast(
`Deal ${res.resourceIdentifier} saved — pending settlement. Settle it from Holdings on or after the settlement date.`
);
this.activeTab = 'holdings';
this.loadPurchases();
},
...this.txns
];
this.toast(`${INSTRUMENT_LABELS[f.bondType]} Certificate ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
error: (err) => this.toast(apiErrorMessage(err))
});
}
/** The optional "More Details" fields, kept with the deal as they were entered. */
private purchaseOtherDetails(): Record<string, string | number> {
const f = this.purchaseForm;
const fields: Record<string, string | number | null> = {
tradeNo: f.tradeNo,
orderNo: f.orderNo,
brokerCode: f.brokerName,
recommAuthority: f.recommAuthority,
sanctionAuthority: f.sanctionAuthority,
meetDate: f.meetDt,
resoNo: f.resoNo,
dealType: f.dealType,
market: f.market,
markCblo: f.markCblo,
paymentMode: f.paymentMode,
deliveryMode: f.deliveryMode,
lien: f.lien,
lienAmount: f.lienAmount,
lienReason: f.lienReason,
bondRating: f.bondRating,
corpBond: f.corpBond,
sglWithBank: f.sglWithBank,
tbillGlAc: f.tbillGlAc,
intType: f.intType,
intDate: f.intDate,
folioNo: f.folioNo,
callOption: f.callOption,
descInv: f.descInv,
fromDist: f.fromDist,
toDist: f.toDist,
amortizeAmt: f.amortizeAmt,
registerAddress: f.registerAdd,
registerCity: f.regiCity,
registerPin: f.regiPin,
remarks: f.remarks,
description: f.description,
tradeAmt: f.tradeAmt,
holdingDays: f.holdingDays,
stampDutyAc: f.stampDutyAc
};
const out: Record<string, string | number> = {};
for (const [
k,
v
] of Object.entries(fields)) {
if (v !== null && v !== undefined && v !== '') {
out[k] = v;
}
}
return out;
}
cancelPurchase(): void {
@@ -576,9 +928,10 @@ export class BondInvestmentComponent {
/** Legacy "Retrieve" button — loads an existing deal back into the form by Cert No. */
retrievePurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo.trim());
const certNo = this.retrieveCertNo.trim();
const h = this.holdings.find((x) => x.certNo === certNo);
if (!h) {
this.toast(`Cert No ${this.purchaseForm.certNo} not found.`);
this.toast(`Cert No ${certNo || '(blank)'} not found.`);
return;
}
this.purchaseForm = {
@@ -587,7 +940,8 @@ export class BondInvestmentComponent {
certNo: h.certNo,
scriptCode: h.scriptCode,
isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '',
partyCode: h.party,
dealNo: h.dealNo ?? '',
partyCode: h.partyCode ?? '',
subType: h.subType,
faceValue: h.faceValue,
considerAmt: h.faceValue,
@@ -599,22 +953,44 @@ export class BondInvestmentComponent {
intType: h.interestFreq,
brokerName: h.brokerCode,
crToAc: h.crToAc,
productName: h.scriptName
productName: h.scriptName,
category: h.category ?? ''
};
this.bondType = h.instrumentType as BondType;
this.toast(`Deal for Cert No ${h.certNo} retrieved.`);
}
dropPurchase(): void {
const certNo = this.purchaseForm.certNo;
if (!this.holdings.some((h) => h.certNo === certNo)) {
this.toast('Nothing to drop — this deal is not yet saved.');
/** Posts a pending deal to GL dated its settlement date; under maker-checker it waits in the Checker Inbox. */
settleDeal(h: InvestmentHolding): void {
if (!h.id) {
return;
}
this.holdings = this.holdings.filter((h) => h.certNo !== certNo);
this.txns = this.txns.filter((t) => t.certNo !== certNo);
this.purchaseForm = this.blankPurchaseForm();
this.toast(`Cert No ${certNo} dropped.`);
this.investmentService.settlePurchase(h.id).subscribe({
next: (res) => {
this.toast(
res.rollbackTransaction
? `Settlement of deal ${h.dealNo} sent for approval (Checker Inbox).`
: `Deal ${h.dealNo} settled and posted to GL (transaction ${res.transactionId}).`
);
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
dropPurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo);
if (!h?.id) {
this.toast('Retrieve a saved deal first, then Drop.');
return;
}
this.investmentService.deletePurchase(h.id).subscribe({
next: () => {
this.toast(`Deal ${h.dealNo} (Cert No ${h.certNo}) dropped.`);
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
exitPurchase(): void {
@@ -658,78 +1034,111 @@ export class BondInvestmentComponent {
}
// -------------------- Interest calculation --------------------
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
* Interest module (it's still collectible on cash basis via Interest Receive there). */
get intCalcTargets(): InvestmentHolding[] {
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.intCalcAll) {
return base;
/** Saved holdings for the Deal No dropdown, narrowed by Type and Script. */
get intDealOptions(): InvestmentHolding[] {
return this.holdings.filter(
(h) =>
h.id != null &&
h.status === 'ACTIVE' &&
(!this.intType || h.instrumentType === this.intType) &&
(!this.intScript || h.scriptCode === this.intScript)
);
}
get intScriptOptions(): typeof SCRIPT_MASTER {
return this.scriptOptions.filter((s) => !this.intType || s.instrumentType === this.intType);
}
onIntFilterChange(): void {
if (this.intScript && !this.intScriptOptions.some((s) => s.scriptCode === this.intScript)) {
this.intScript = '';
}
return base.filter((h) => {
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
return true;
});
}
get intCalcExcludedNpi(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onIntCalcTypeChange(): void {
if (this.intCalcType) {
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
if (this.intPurchaseId != null && !this.intDealOptions.some((h) => h.id === this.intPurchaseId)) {
this.intPurchaseId = null;
}
this.intSchedule = null;
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
}
processInterest(): void {
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) {
this.toast('No holdings have interest due to be posted for this period.');
retrieveInterestSchedule(): void {
if (this.intPurchaseId == null) {
this.toast('Select a Deal No first.');
return;
}
const toDate = this.intCalcToDate || this.asOnDate;
const fromDate = this.intCalcFromDate || toDate;
const glCode = this.intCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.interestAccrued(h);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
this.intLoading = true;
this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({
next: (schedule) => {
this.intSchedule = schedule;
this.intBankGlCode = schedule.bankGlCode;
this.intLoading = false;
},
error: (err) => {
this.intSchedule = null;
this.intLoading = false;
this.toast(apiErrorMessage(err));
}
});
this.txns = [
...newTxns,
...this.txns
];
this.intCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
loadDueInterest(): void {
this.investmentService.getDueInterest().subscribe({
next: (tasks) => (this.intDueTasks = tasks),
error: () => (this.intDueTasks = [])
});
}
/** Opens a due task's holding in the schedule below. */
openDueTask(t: InvestmentInterestDueDto): void {
this.intType = '';
this.intScript = '';
this.intPurchaseId = t.purchaseId;
this.retrieveInterestSchedule();
}
/** Lines shown: up to 31 December of next year, plus anything already posted — unless the full schedule is asked for. */
get intVisibleRows(): InvestmentInterestRowDto[] {
const rows = this.intSchedule?.rows ?? [];
if (this.intShowFullSchedule) {
return rows;
}
const upTo = new Date(new Date().getFullYear() + 1, 11, 31);
return rows.filter((r) => r.posted || parseDDMMYYYY(r.intDate) <= upTo);
}
/** Posts one interest line with its JV voucher (passed as it is raised), then refreshes the schedule and tasks. */
generateVoucher(r: InvestmentInterestRowDto): void {
const sc = this.intSchedule;
if (!sc || this.intPosting) {
return;
}
const bankGlAccountId =
r.type === 'COUPON' ? (this.glIdByCode.get(this.intBankGlCode) ?? sc.bankGlAccountId) : undefined;
const body = {
lineType: r.type,
intDate: r.intDate,
...(bankGlAccountId ? { bankGlAccountId } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.intPosting = true;
this.investmentService.postInterest(sc.purchaseId, body).subscribe({
next: (res) => {
this.intPosting = false;
this.toast(
`${r.type === 'COUPON' ? 'Coupon' : 'Provision'} of ${r.intDate} posted — voucher ${res.resourceIdentifier ?? ''}.`
);
this.retrieveInterestSchedule();
this.loadDueInterest();
},
error: (err) => {
this.intPosting = false;
this.toast(apiErrorMessage(err));
}
});
}
/** Total of the lines falling due up to the as-on date. */
get intDueTotal(): number {
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
}
// -------------------- Sell / Redeem Entry --------------------
+477 -28
View File
@@ -17,7 +17,7 @@
*/
export type InstrumentType = 'FD' | 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND' | 'SHARE';
export type HoldingStatus = 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
export type HoldingStatus = 'PENDING_SETTLEMENT' | 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | 'CUMULATIVE';
export type TxnType =
| 'PURCHASE'
@@ -123,6 +123,20 @@ export interface InvestmentHolding {
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
* or repeated period always yields 0 instead of double-posting the same interest. */
interestPostedTillDate?: string;
/** RBI investment category chosen at purchase — HTM / AFS / HFT. */
category?: string;
/** SGL / CSGL account the security is held in (custody, not an accounting GL). */
sglAccountNo?: string;
/** Backend id / deal number / party code once the purchase is saved (absent on sample rows). */
id?: number;
dealNo?: string;
partyCode?: string;
bookValue?: number;
settlementAmount?: number;
/** Settlement date of the deal (deal date + T+n) — it can be settled on or after this day. */
settlementDate?: string;
/** Journal entry transaction posted when the deal was settled. */
glTransactionId?: string;
}
export interface InvestmentTxn {
@@ -248,6 +262,80 @@ export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, to
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
}
/** Days between two DD-MM-YYYY dates on a 30/360 basis (every month 30 days), as the bank's existing
* CBS counts them — e.g. 10-08 → 30-09 = 50 days, 10-08 → 07-10 = 57 days. */
export function days360(fromStr: string, toStr: string): number {
const a = parseDDMMYYYY(fromStr);
const b = parseDDMMYYYY(toStr);
if (isNaN(a.getTime()) || isNaN(b.getTime()) || b <= a) {
return 0;
}
const d1 = Math.min(a.getDate(), 30);
const d2 = Math.min(b.getDate(), 30);
return (b.getFullYear() - a.getFullYear()) * 360 + (b.getMonth() - a.getMonth()) * 30 + (d2 - d1);
}
/** Most recent half-yearly coupon date on or before `onStr`, derived from the maturity date (coupons
* fall on the maturity day/month and six months either side of it). */
export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
const on = parseDDMMYYYY(onStr);
if (isNaN(mat.getTime()) || isNaN(on.getTime())) {
return '';
}
const day = mat.getDate();
const couponOn = (year: number, month: number): Date => {
const lastDay = new Date(year, month + 1, 0).getDate();
return new Date(year, month, Math.min(day, lastDay));
};
const months = [
mat.getMonth() % 6,
(mat.getMonth() % 6) + 6
];
let best: Date | undefined;
for (const year of [
on.getFullYear() - 1,
on.getFullYear()
]) {
for (const m of months) {
const c = couponOn(year, m);
if (c <= on && (!best || c > best)) {
best = c;
}
}
}
return best ? formatDDMMYYYY(best) : '';
}
/** First half-yearly coupon date after `onStr` (coupons fall on the maturity day/month and six months
* either side), never later than maturity. */
export function nextHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const last = lastHalfYearlyCouponDate(maturityStr, onStr);
const mat = parseDDMMYYYY(maturityStr);
if (!last || isNaN(mat.getTime())) {
return '';
}
const l = parseDDMMYYYY(last);
const year = l.getFullYear() + (l.getMonth() >= 6 ? 1 : 0);
const month = (l.getMonth() + 6) % 12;
const next = new Date(year, month, Math.min(mat.getDate(), new Date(year, month + 1, 0).getDate()));
return formatDDMMYYYY(next > mat ? mat : next);
}
/** The two coupon days of a half-yearly security, e.g. "14 Feb / 14 Aug". */
export function couponDatesLabel(maturityStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
if (isNaN(mat.getTime())) {
return '';
}
const label = (month: number): string => {
const day = Math.min(mat.getDate(), new Date(2001, month + 1, 0).getDate());
return `${day} ${new Date(2001, month, 1).toLocaleString('en-US', { month: 'short' })}`;
};
const first = mat.getMonth() % 6;
return `${label(first)} / ${label(first + 6)}`;
}
export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
@@ -1404,99 +1492,331 @@ export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
];
/** Investment GLs a script / party can be mapped to. PROPOSED codes awaiting approval — the
* backend will take these from the chart of accounts, never hard-coded. */
export const INVESTMENT_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100701', glName: 'INVESTMENT IN CENTRAL GOVT SECURITIES' },
{ glCode: '100702', glName: 'INVESTMENT IN STATE GOVT SECURITIES' },
{ glCode: '100703', glName: 'INVESTMENT IN BONDS / DEBENTURES' },
{ glCode: '100704', glName: 'FIXED DEPOSITS WITH OTHER BANKS' },
{ glCode: '100705', glName: 'INVESTMENT IN MUTUAL FUNDS' },
{ glCode: '100706', glName: 'INVESTMENT IN SHARES' }
];
/** Fallback investment GL when a script has none mapped. */
export const INVESTMENT_GL_BY_TYPE: Record<InstrumentType, string> = {
GOVT_SECURITY: '100701',
NON_SLR: '100703',
BOND: '100703',
FD: '100704',
MUTUAL_FUND: '100705',
SHARE: '100706'
};
/** Broken-period interest paid to the seller at purchase — an expense, not part of cost (RBI UCB Investment
* Directions para 152). */
export const BROKEN_PERIOD_INTEREST_GL: InvestmentGlOption = {
glCode: '500606',
glName: 'BROKEN PERIOD INTEREST PAID ON INVESTMENTS'
};
/** Charges on an investment deal — expenses on their own GLs, not part of the book value (Jitendra Sir). */
export const STAMP_DUTY_GL: InvestmentGlOption = { glCode: '500601', glName: 'STAMP DUTY ON INVESTMENTS' };
export const BROKERAGE_GL: InvestmentGlOption = { glCode: '500602', glName: 'BROKERAGE ON INVESTMENTS' };
export const OTHER_CHARGES_GL: InvestmentGlOption = { glCode: '500603', glName: 'OTHER CHARGES ON INVESTMENTS' };
/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */
export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = {
glCode: '100603',
glName: 'INTEREST RECEIVABLE ON INVESTMENTS (PROVISION)'
};
/** Bank accounts a purchase can be paid from — these GLs already exist in the chart of accounts. */
export const SETTLEMENT_BANK_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100201', glName: 'IDBI BANK' },
{ glCode: '100202', glName: 'YES BANK' }
];
/** Every GL the Investment screens can show by name. */
export const INVESTMENT_GL_LOOKUP: InvestmentGlOption[] = [
...INVESTMENT_GL_OPTIONS,
INTEREST_RECEIVABLE_GL,
...SETTLEMENT_BANK_GL_OPTIONS
];
/** SLR classification on the Script Master, as in the bank's existing CBS ("GOI" = Central Govt). */
export type SlrType = 'GOI' | 'STATE_GOVT' | 'TREASURY' | 'NON_SLR';
export const SLR_TYPE_OPTIONS: Array<{ value: SlrType; label: string }> = [
{ value: 'GOI', label: 'GOI' },
{ value: 'STATE_GOVT', label: 'STATE GOVT' },
{ value: 'TREASURY', label: 'TREASURY' },
{ value: 'NON_SLR', label: 'NON SLR' }
];
export interface ScriptMaster {
/** Backend id once the script is saved (absent on the sample rows). */
id?: number;
scriptCode: string;
scriptName: string;
instrumentType: InstrumentType;
isin: string;
faceValuePerUnit: number;
// Fields below follow the bank's existing CBS Script Master; a purchase picks them up instead
// of the dealer re-typing them on every deal.
slrType?: SlrType;
/** Investment GL this script is booked to. */
glAccountId?: number;
glCode?: string;
glName?: string;
active?: boolean;
/** Interest (coupon) rate % per annum. */
couponRate?: number;
issueDate?: string;
maturityDate?: string;
// Issuer details — used for Bond / Mutual Fund scripts.
companyName?: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
corpCode?: string;
}
/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */
export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } {
const none = { years: 0, months: 0, days: 0 };
if (!fromStr || !toStr) {
return none;
}
const from = parseDDMMYYYY(fromStr);
const to = parseDDMMYYYY(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime()) || to < from) {
return none;
}
let years = to.getFullYear() - from.getFullYear();
let months = to.getMonth() - from.getMonth();
let days = to.getDate() - from.getDate();
if (days < 0) {
months -= 1;
days += new Date(to.getFullYear(), to.getMonth(), 0).getDate();
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years, months, days };
}
export const SCRIPT_MASTER: ScriptMaster[] = [
{ scriptCode: 'FD-CUM', scriptName: 'Cumulative Fixed Deposit', instrumentType: 'FD', isin: '', faceValuePerUnit: 1 },
{
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
instrumentType: 'FD',
isin: '',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
},
{
scriptCode: 'FD-MIP',
scriptName: 'Monthly Interest Payout FD',
instrumentType: 'FD',
isin: '',
faceValuePerUnit: 1
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
},
{
scriptCode: 'IN0020230012',
scriptName: '7.26% GOI 2033',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020230012',
faceValuePerUnit: 100
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.26,
issueDate: '15-01-2023',
maturityDate: '15-01-2033'
},
{
scriptCode: 'IN0020220089',
scriptName: '7.10% GOI 2029',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020220089',
faceValuePerUnit: 100
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.1,
issueDate: '10-03-2022',
maturityDate: '10-03-2029'
},
{
scriptCode: 'IN0020210056',
scriptName: '6.79% GOI 2027',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020210056',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 6.79,
issueDate: '05-06-2021',
maturityDate: '05-06-2027'
},
{
scriptCode: 'IN0020240034',
scriptName: '7.18% GOI 2037',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020240034',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.18,
issueDate: '22-04-2024',
maturityDate: '22-04-2037'
},
{
scriptCode: 'MH-SDL-2031',
scriptName: '8.05% Maharashtra SDL 2031',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 8.05,
issueDate: '15-02-2021',
maturityDate: '15-02-2031'
},
{
scriptCode: 'GJ-SDL-2026',
scriptName: '7.85% Gujarat SDL 2026',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 7.85,
issueDate: '18-09-2016',
maturityDate: '18-09-2026'
},
{
scriptCode: 'TBILL-364D',
scriptName: '364 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100
faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '02-07-2026',
maturityDate: '02-07-2027'
},
{
scriptCode: 'TBILL-91D',
scriptName: '91 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '15-05-2026',
maturityDate: '13-08-2026'
},
{
scriptCode: 'REC-7.5-2031',
scriptName: 'REC Ltd Bond 7.50% 2031',
instrumentType: 'NON_SLR',
isin: 'INE020B08JN0',
faceValuePerUnit: 100
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.5,
issueDate: '11-08-2021',
maturityDate: '11-08-2031',
companyName: 'REC Limited',
city: 'New Delhi'
},
{
scriptCode: 'NABARD-7.35-2030',
scriptName: 'NABARD Bond 7.35% 2030',
instrumentType: 'NON_SLR',
isin: 'INE261F09EU1',
faceValuePerUnit: 100
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.35,
issueDate: '20-09-2020',
maturityDate: '20-09-2030',
companyName: 'NABARD',
city: 'Mumbai'
},
{
scriptCode: 'LTF-8.1-2028',
scriptName: 'L&T Finance NCD 8.10% 2028',
instrumentType: 'BOND',
isin: 'INE027E07BR5',
faceValuePerUnit: 100
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 8.1,
companyName: 'L&T Finance Ltd',
city: 'Mumbai'
},
{
scriptCode: 'BAF-7.95-2027',
scriptName: 'Bajaj Finance NCD 7.95% 2027',
instrumentType: 'BOND',
isin: 'INE296A07SP0',
faceValuePerUnit: 100
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.95,
companyName: 'Bajaj Finance Ltd',
city: 'Pune'
},
{
scriptCode: 'SBI-OVN',
scriptName: 'SBI Overnight Fund',
instrumentType: 'MUTUAL_FUND',
isin: 'INF200KA1Q40',
faceValuePerUnit: 1000
faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'SBI Mutual Fund',
city: 'Mumbai'
},
{
scriptCode: 'HDFC-LIQ',
scriptName: 'HDFC Liquid Fund',
instrumentType: 'MUTUAL_FUND',
isin: 'INF179KA1WR9',
faceValuePerUnit: 1000
faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'HDFC Mutual Fund',
city: 'Mumbai'
},
{
scriptCode: 'SBIN',
scriptName: 'State Bank of India',
instrumentType: 'SHARE',
isin: 'INE062A01020',
faceValuePerUnit: 1
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
},
{
scriptCode: 'HDFCBANK',
scriptName: 'HDFC Bank Ltd',
instrumentType: 'SHARE',
isin: 'INE040A01034',
faceValuePerUnit: 1
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
}
];
@@ -1561,23 +1881,150 @@ export const BROKER_MASTER: BrokerMaster[] = [
];
export interface PartyMaster {
/** Backend id once the party is saved (absent on the sample rows). */
id?: number;
partyCode: string;
partyName: string;
partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY';
panNo: string;
panNo?: string;
// Fields below follow the bank's existing CBS Bank / Party Master.
bondType?: InstrumentType;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
bankType?: string;
/** GL this party is mapped to. */
glAccountId?: number;
glCode?: string;
glName?: string;
rbiCode?: string;
/** Exposure limit for this party (₹). */
limit?: number | null;
leiNo?: string;
// Counterparty's bank details
cpBankName?: string;
cpBranchName?: string;
cpAccountNo?: string;
cpIfsc?: string;
/** Absent means ACTIVE. An INACTIVE party is not offered for new deals. */
status?: 'ACTIVE' | 'INACTIVE';
}
/** Sample Bank Type options until the bank confirms its list. */
export const BANK_TYPE_OPTIONS = [
'SCHEDULED COMMERCIAL BANK',
'STATE CO-OPERATIVE BANK',
'DISTRICT CENTRAL CO-OPERATIVE BANK',
'URBAN CO-OPERATIVE BANK',
'RBI / GOVERNMENT',
'OTHER'
];
export const PARTY_MASTER: PartyMaster[] = [
{ partyCode: 'PTY-001', partyName: 'Jila Sahakari Kendriya Bank', partyType: 'BANK', panNo: 'AABCJ1234K' },
{ partyCode: 'PTY-002', partyName: 'State Cooperative Bank Ltd', partyType: 'BANK', panNo: 'AABCS5678L' },
{ partyCode: 'PTY-003', partyName: 'RBI - SGL', partyType: 'GOVT', panNo: 'AAACR4849R' },
{ partyCode: 'PTY-004', partyName: 'REC Limited', partyType: 'CORPORATE', panNo: 'AAACR0641G' },
{ partyCode: 'PTY-005', partyName: 'NABARD', partyType: 'CORPORATE', panNo: 'AAACN0272B' },
{ partyCode: 'PTY-006', partyName: 'L&T Finance Ltd', partyType: 'CORPORATE', panNo: 'AAACL0140P' },
{ partyCode: 'PTY-007', partyName: 'SBI Mutual Fund', partyType: 'AMC', panNo: 'AAATS4325L' },
{ partyCode: 'PTY-008', partyName: 'HDFC Mutual Fund', partyType: 'AMC', panNo: 'AAATH1234M' },
{ partyCode: 'PTY-009', partyName: 'NSE/BSE', partyType: 'EXCHANGE', panNo: 'AAACN0028G' },
{ partyCode: 'PTY-010', partyName: 'District Cooperative Bank', partyType: 'COOP_SOCIETY', panNo: 'AABCD9988K' }
{
partyCode: 'PTY-001',
partyName: 'Jila Sahakari Kendriya Bank',
partyType: 'BANK',
bondType: 'FD',
glCode: '100704',
city: 'Vidisha',
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
status: 'ACTIVE'
},
{
partyCode: 'PTY-002',
partyName: 'State Cooperative Bank Ltd',
partyType: 'BANK',
bondType: 'FD',
glCode: '100704',
city: 'Bhopal',
bankType: 'STATE CO-OPERATIVE BANK',
status: 'ACTIVE'
},
{
partyCode: 'PTY-003',
partyName: 'RBI - SGL',
partyType: 'GOVT',
bondType: 'GOVT_SECURITY',
glCode: '100701',
city: 'Mumbai',
bankType: 'RBI / GOVERNMENT',
status: 'ACTIVE'
},
{
partyCode: 'PTY-004',
partyName: 'REC Limited',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'New Delhi',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-005',
partyName: 'NABARD',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-006',
partyName: 'L&T Finance Ltd',
partyType: 'CORPORATE',
bondType: 'BOND',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-007',
partyName: 'SBI Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-008',
partyName: 'HDFC Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-009',
partyName: 'NSE/BSE',
partyType: 'EXCHANGE',
bondType: 'SHARE',
glCode: '100706',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-010',
partyName: 'District Cooperative Bank',
partyType: 'COOP_SOCIETY',
bondType: 'SHARE',
glCode: '100706',
city: 'Vidisha',
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
status: 'ACTIVE'
}
];
export interface SubTypeMaster {
@@ -1725,13 +2172,15 @@ export const LIEN_REASON_MASTER: LienReason[] = [
/** Daily Script Master (legacy menu "09") — the day's quoted rate per script, entered/imported
* every working day. Distinct from Script Market Detail ("13") which also carries YTM/valuation. */
export interface DailyScriptRate {
/** Backend id once saved. */
id?: number;
rateDate: string;
scriptCode: string;
scriptName: string;
openRate: number;
openRate: number | null;
closeRate: number;
highRate: number;
lowRate: number;
highRate: number | null;
lowRate: number | null;
}
export const DAILY_SCRIPT_RATES: DailyScriptRate[] = [
@@ -106,38 +106,112 @@
</button>
</div>
<!-- PARTY MASTER -->
<!-- PARTY MASTER (layout follows the bank's existing CBS Bank / Party Master) -->
@if (activeTab === 'party') {
<div class="pg-card">
<div class="pos-section-label">Add Bank / Party</div>
<div class="pos-section-label">Bank / Party Master — Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
/></label>
<label class="inv-form-field">
<span>Party Type *</span>
<select [(ngModel)]="newParty.partyType">
<span>* Bond Type</span>
<select [(ngModel)]="partyForm.bondType">
<option [ngValue]="undefined">-- Select --</option>
<option value="BANK">BANK</option>
<option value="CORPORATE">CORPORATE</option>
<option value="GOVT">GOVT</option>
<option value="AMC">AMC</option>
<option value="EXCHANGE">EXCHANGE</option>
<option value="COOP_SOCIETY">COOP SOCIETY</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label>
<label class="inv-form-field"
><span>* Party Code</span><input type="text" [value]="partyForm.partyCode || nextPartyCode" readonly
/></label>
<label class="inv-form-field"
><span>* Party Name</span><input type="text" [(ngModel)]="partyForm.partyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="partyForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="partyForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="partyForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="partyForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="partyForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="partyForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="partyForm.fax" /></label>
<label class="inv-form-field">
<span>Bank Type</span>
<select [(ngModel)]="partyForm.bankType">
<option [ngValue]="undefined">-- Select --</option>
@for (b of bankTypeOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="partyForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of investmentGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>RBI Code</span><input type="text" [(ngModel)]="partyForm.rbiCode" /></label>
<label class="inv-form-field"
><span>Limit (₹)</span><input type="number" min="0" [(ngModel)]="partyForm.limit"
/></label>
<label class="inv-form-field"><span>LEI No</span><input type="text" [(ngModel)]="partyForm.leiNo" /></label>
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="partyForm.status">
<option [ngValue]="undefined">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</label>
</div>
<div class="pos-section-label">Counterparty's Bank Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Name of Bank</span><input type="text" [(ngModel)]="partyForm.cpBankName"
/></label>
<label class="inv-form-field"
><span>Name of Branch</span><input type="text" [(ngModel)]="partyForm.cpBranchName"
/></label>
<label class="inv-form-field"
><span>Account No.</span><input type="text" [(ngModel)]="partyForm.cpAccountNo"
/></label>
<label class="inv-form-field"
><span>IFSC</span><input type="text" maxlength="11" [(ngModel)]="partyForm.cpIfsc"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button>
<label class="inv-form-field"
><span>Search Party Code</span><input type="text" placeholder="e.g. PTY-001" [(ngModel)]="partySearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveParty()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchParty()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropParty()">Drop</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Exit</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Party Code</th>
<th>Bond Type</th>
<th>Party Name</th>
<th>Type</th>
<th>PAN No</th>
<th>City</th>
<th>Bank Type</th>
<th>GL A/c</th>
<th class="num">Limit</th>
<th>Counterparty Bank / IFSC</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@@ -146,9 +220,14 @@
<td>
<span class="pg-chip">{{ p.partyCode }}</span>
</td>
<td>{{ p.bondType || '—' }}</td>
<td>{{ p.partyName }}</td>
<td>{{ p.partyType }}</td>
<td>{{ p.panNo }}</td>
<td>{{ p.city || '—' }}</td>
<td>{{ p.bankType || '—' }}</td>
<td>{{ p.glCode ? p.glCode + ' — ' + glName(p.glCode) : '—' }}</td>
<td class="num">{{ (p.limit ?? null) !== null ? '₹' + (p.limit | number: '1.0-0') : '—' }}</td>
<td>{{ p.cpBankName ? p.cpBankName + (p.cpIfsc ? ' / ' + p.cpIfsc : '') : '—' }}</td>
<td>{{ p.status ?? 'ACTIVE' }}</td>
</tr>
}
</tbody>
@@ -156,42 +235,118 @@
</div>
}
<!-- SCRIPT MASTER -->
<!-- SCRIPT MASTER (layout follows the bank's existing CBS Script Master) -->
@if (activeTab === 'script') {
<div class="pg-card">
<div class="pos-section-label">Add Script</div>
<div class="pos-section-label">Script Master — Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
/></label>
<label class="inv-form-field">
<span>Instrument Type *</span>
<select [(ngModel)]="newScript.instrumentType">
<span>* Bond Type</span>
<select [(ngModel)]="scriptForm.instrumentType" [disabled]="!!scriptForm.scriptCode">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
<label class="inv-form-field">
<span>SLR</span>
<select [(ngModel)]="scriptForm.slrType">
<option [ngValue]="undefined">-- Select --</option>
@for (o of slrTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
><span>* Script Code</span><input type="text" [value]="scriptForm.scriptCode || nextScriptCode" readonly
/></label>
<label class="inv-form-field"
><span>* Script Desc</span
><input type="text" placeholder="e.g. 7.50%GOI2034" [(ngModel)]="scriptForm.scriptName"
/></label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="scriptForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of investmentGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Issue Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Redum / Mat Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.maturityDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period (Year)</span><input type="text" [value]="scriptPeriod.years" readonly
/></label>
<label class="inv-form-field"
><span>Period (Month)</span><input type="text" [value]="scriptPeriod.months" readonly
/></label>
<label class="inv-form-field"
><span>Period (Days)</span><input type="text" [value]="scriptPeriod.days" readonly
/></label>
</div>
<div class="pos-section-label">Company Details (Bond / Mutual Fund)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Company Name</span><input type="text" [(ngModel)]="scriptForm.companyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="scriptForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="scriptForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="scriptForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="scriptForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="scriptForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="scriptForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="scriptForm.fax" /></label>
<label class="inv-form-field"
><span>Corp. Code</span><input type="text" [(ngModel)]="scriptForm.corpCode"
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button>
<label class="inv-form-field"
><span>Search Script Code</span><input type="text" placeholder="e.g. G00001" [(ngModel)]="scriptSearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveScript()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchScript()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Exit</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Script Code</th>
<th>Script Name</th>
<th>Instrument</th>
<th>Bond Type</th>
<th>SLR</th>
<th>Script Desc</th>
<th>GL A/c</th>
<th>ISIN</th>
<th class="num">Face Value/Unit</th>
<th class="num">Int Rate %</th>
<th>Issue Date</th>
<th>Maturity</th>
</tr>
</thead>
<tbody>
@@ -200,10 +355,14 @@
<td>
<span class="pg-chip">{{ s.scriptCode }}</span>
</td>
<td>{{ s.scriptName }}</td>
<td>{{ s.instrumentType }}</td>
<td>{{ s.slrType || '—' }}</td>
<td>{{ s.scriptName }}</td>
<td>{{ s.glCode || '—' }}</td>
<td>{{ s.isin || '—' }}</td>
<td class="num">{{ s.faceValuePerUnit }}</td>
<td class="num">{{ s.couponRate ?? '—' }}</td>
<td>{{ s.issueDate || '—' }}</td>
<td>{{ s.maturityDate || '—' }}</td>
</tr>
}
</tbody>
@@ -386,10 +545,11 @@
<th class="num">High</th>
<th class="num">Low</th>
<th class="num">Close</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) {
@for (r of dailyScriptRates; track r.id ?? r.rateDate + r.scriptCode) {
<tr>
<td>{{ r.rateDate }}</td>
<td>
@@ -400,6 +560,13 @@
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No rates entered yet.</td>
</tr>
}
</tbody>
@@ -6,20 +6,17 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
SCRIPT_MASTER,
BROKER_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
CONTROL_PARAMETERS,
MUTUAL_FUND_PRODUCTS,
SGL_BANK_MASTER,
SCRIPT_MARKET_DETAIL,
DAILY_SCRIPT_RATES,
LIEN_REASON_MASTER,
ScriptMaster,
BrokerMaster,
@@ -31,9 +28,14 @@ import {
ScriptMarketDetail,
DailyScriptRate,
LienReason,
InstrumentType
InstrumentType,
BANK_TYPE_OPTIONS,
SLR_TYPE_OPTIONS,
INVESTMENT_GL_LOOKUP,
periodBetween
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
import { InvestmentGlAccount, InvestmentService, apiErrorMessage, toDailyScriptRate } from '../investment.service';
type MasterTab =
| 'party'
@@ -65,18 +67,19 @@ type MasterTab =
DateDdmmyyyyPickerComponent
]
})
export class InvestmentMastersComponent {
export class InvestmentMastersComponent implements OnInit {
activeTab: MasterTab = 'party';
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
scripts: ScriptMaster[] = [];
brokers: BrokerMaster[] = [...BROKER_MASTER];
parties: PartyMaster[] = [...PARTY_MASTER];
parties: PartyMaster[] = [];
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES];
/** Loaded from the backend (Daily Script Master) in ngOnInit. */
dailyScriptRates: DailyScriptRate[] = [];
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
readonly instrumentTypes: InstrumentType[] = [
@@ -106,8 +109,19 @@ export class InvestmentMastersComponent {
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
newParty: Partial<PartyMaster> = {};
newScript: Partial<ScriptMaster> = {};
// Bank / Party Master and Script Master follow the bank's existing CBS screens (one entry form
// with Save / Search / Cancel / Drop / Exit) and are stored in the backend through
// InvestmentService; the other master tabs are still sample data.
private readonly investmentService = inject(InvestmentService);
partyForm: Partial<PartyMaster> = {};
partySearchCode = '';
scriptForm: Partial<ScriptMaster> = {};
scriptSearchCode = '';
saving = false;
readonly bankTypeOptions = BANK_TYPE_OPTIONS;
readonly slrTypeOptions = SLR_TYPE_OPTIONS;
/** Enabled ASSET detail GLs from the chart of accounts. */
investmentGlOptions: InvestmentGlAccount[] = [];
newSubType: Partial<SubTypeMaster> = {};
newBroker: Partial<BrokerMaster> = {};
newControlParam: Partial<ControlParameter> = {};
@@ -116,43 +130,217 @@ export class InvestmentMastersComponent {
newSglBank: Partial<SglBankMaster> = {};
newDailyRate: Partial<DailyScriptRate> = {};
ngOnInit(): void {
this.loadDailyRates();
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) => (this.investmentGlOptions = gls),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.reloadMasters();
}
setTab(tab: MasterTab): void {
this.activeTab = tab;
}
addParty(): void {
if (!this.newParty.partyName || !this.newParty.partyType) {
this.toast('Enter Party Name and Type.');
return;
}
this.parties = [
{
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`,
panNo: '',
...this.newParty
} as PartyMaster,
...this.parties
];
this.newParty = {};
this.toast('Party/Bank master added.');
/** Re-reads both masters from the backend (also refreshes the lists the Purchase screen uses). */
private reloadMasters(): void {
this.investmentService.syncSharedMasters().subscribe({
next: ({ parties, scripts }) => {
this.parties = parties;
this.scripts = scripts;
},
error: (err) => this.toast(`Could not load masters: ${apiErrorMessage(err)}`)
});
}
addScript(): void {
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
this.toast('Enter Script Name and Instrument Type.');
glName(glCode?: string): string {
return (
this.investmentGlOptions.find((g) => g.glCode === glCode)?.name ??
INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ??
''
);
}
// -------------------- Bank / Party Master --------------------
/** Preview of the code the backend will assign; the saved code always comes from the backend. */
get nextPartyCode(): string {
const nums = this.parties.map((p) => parseInt(p.partyCode.replace(/\D/g, ''), 10)).filter((n) => !isNaN(n));
return `PTY-${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(3, '0')}`;
}
saveParty(): void {
const f = this.partyForm;
if (!f.bondType || !f.partyName || !f.glAccountId) {
this.toast('Enter Bond Type, Party Name and GL A/c No.');
return;
}
this.scripts = [
{
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`,
isin: '',
faceValuePerUnit: 100,
...this.newScript
} as ScriptMaster,
...this.scripts
];
this.newScript = {};
this.toast('Script master added.');
const body = {
bondType: f.bondType,
partyName: f.partyName,
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
bankType: f.bankType ?? '',
glAccountId: Number(f.glAccountId),
rbiCode: f.rbiCode ?? '',
limit: f.limit ?? null,
leiNo: f.leiNo ?? '',
cpBankName: f.cpBankName ?? '',
cpBranchName: f.cpBranchName ?? '',
cpAccountNo: f.cpAccountNo ?? '',
cpIfsc: f.cpIfsc ?? '',
active: f.status !== 'INACTIVE',
locale: 'en'
};
const request = f.id ? this.investmentService.updateParty(f.id, body) : this.investmentService.createParty(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(`Party ${res.resourceIdentifier ?? f.partyCode} ${f.id ? 'updated' : 'saved'}.`);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
searchParty(): void {
const p = this.parties.find((x) => x.partyCode.toUpperCase() === this.partySearchCode.trim().toUpperCase());
if (!p) {
this.toast(`Party Code ${this.partySearchCode} not found.`);
return;
}
this.partyForm = { ...p };
this.toast(`Party ${p.partyCode} loaded.`);
}
cancelParty(): void {
this.partyForm = {};
this.partySearchCode = '';
}
dropParty(): void {
const f = this.partyForm;
if (!f.id) {
this.toast('Search a saved party first, then Drop.');
return;
}
this.investmentService.deleteParty(f.id).subscribe({
next: () => {
this.toast(`Party ${f.partyCode} dropped.`);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
// -------------------- Script Master --------------------
/** Coupon / maturity terms only apply to debt securities. */
get scriptIsDebtSecurity(): boolean {
return [
'GOVT_SECURITY',
'NON_SLR',
'BOND'
].includes(this.scriptForm.instrumentType ?? '');
}
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
get nextScriptCode(): string {
const prefixByType: Record<InstrumentType, string> = {
GOVT_SECURITY: 'G',
NON_SLR: 'N',
BOND: 'B',
MUTUAL_FUND: 'M',
FD: 'F',
SHARE: 'S'
};
const type = this.scriptForm.instrumentType;
if (!type) {
return '';
}
const prefix = prefixByType[type];
const pattern = new RegExp(`^${prefix}(\\d{5})$`);
const nums = this.scripts
.map((s) => pattern.exec(s.scriptCode)?.[1])
.filter((n): n is string => !!n)
.map((n) => parseInt(n, 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(5, '0')}`;
}
get scriptPeriod(): { years: number; months: number; days: number } {
return periodBetween(this.scriptForm.issueDate, this.scriptForm.maturityDate);
}
saveScript(): void {
const f = this.scriptForm;
if (!f.instrumentType || !f.scriptName || !f.glAccountId) {
this.toast('Enter Bond Type, Script Desc and GL A/c No.');
return;
}
if (this.scriptIsDebtSecurity && (!f.isin || f.couponRate == null || !f.issueDate)) {
this.toast('Enter ISIN, Int Rate and Issue Date for this security.');
return;
}
const body = {
bondType: f.instrumentType,
slrType: f.slrType ?? '',
scriptDesc: f.scriptName,
glAccountId: Number(f.glAccountId),
isin: f.isin ?? '',
intRate: f.couponRate ?? null,
issueDate: f.issueDate ?? '',
maturityDate: f.maturityDate ?? '',
companyName: f.companyName ?? '',
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
corpCode: f.corpCode ?? '',
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
const request = f.id ? this.investmentService.updateScript(f.id, body) : this.investmentService.createScript(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(`Script ${res.resourceIdentifier ?? f.scriptCode} ${f.id ? 'updated' : 'saved'}.`);
this.scriptForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
searchScript(): void {
const s = this.scripts.find((x) => x.scriptCode.toUpperCase() === this.scriptSearchCode.trim().toUpperCase());
if (!s) {
this.toast(`Script Code ${this.scriptSearchCode} not found.`);
return;
}
this.scriptForm = { ...s };
this.toast(`Script ${s.scriptCode} loaded.`);
}
cancelScript(): void {
this.scriptForm = {};
this.scriptSearchCode = '';
}
addSubType(): void {
@@ -248,24 +436,56 @@ export class InvestmentMastersComponent {
this.toast('SGL Bank master added.');
}
private loadDailyRates(): void {
this.investmentService.getDailyRates().subscribe({
next: (rows) => (this.dailyScriptRates = rows.map(toDailyScriptRate)),
error: (err) => this.toast(`Could not load daily script rates: ${apiErrorMessage(err)}`)
});
}
addDailyRate(): void {
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) {
const r = this.newDailyRate;
if (!r.scriptCode || !r.rateDate || r.closeRate == null) {
this.toast('Enter Rate Date, Script Code and Close Rate.');
return;
}
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode);
this.dailyScriptRates = [
{
scriptName: script?.scriptName || '',
openRate: this.newDailyRate.closeRate,
highRate: this.newDailyRate.closeRate,
lowRate: this.newDailyRate.closeRate,
...this.newDailyRate
} as DailyScriptRate,
...this.dailyScriptRates
];
this.newDailyRate = {};
this.toast('Daily script rate added.');
const script = this.scripts.find((s) => s.scriptCode === r.scriptCode);
if (!script?.id) {
this.toast(`Script ${r.scriptCode} is not saved in Script Master.`);
return;
}
const body = {
scriptId: script.id,
rateDate: r.rateDate,
closeRate: r.closeRate,
...(r.openRate != null ? { openRate: r.openRate } : {}),
...(r.highRate != null ? { highRate: r.highRate } : {}),
...(r.lowRate != null ? { lowRate: r.lowRate } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.investmentService.createDailyRate(body).subscribe({
next: () => {
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} saved.`);
this.newDailyRate = {};
this.loadDailyRates();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
/** A wrong rate is corrected by dropping it and entering it again. */
dropDailyRate(r: DailyScriptRate): void {
if (!r.id) {
return;
}
this.investmentService.deleteDailyRate(r.id).subscribe({
next: () => {
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} dropped.`);
this.loadDailyRates();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
// -------------------- Export to Excel --------------------
@@ -279,46 +499,121 @@ export class InvestmentMastersComponent {
return one(
[
'Party Code',
'Bond Type',
'Party Name',
'Type',
'PAN No'
'Address',
'City',
'Pincode',
'Phone',
'Fax',
'Bank Type',
'GL A/c No',
'RBI Code',
'Limit',
'LEI No',
'Counterparty Bank',
'Branch',
'Account No',
'IFSC',
'Status'
],
[
12,
14,
30,
16,
14
36,
14,
10,
14,
14,
24,
12,
12,
14,
22,
24,
20,
18,
14,
10
],
this.parties.map((p) => [
p.partyCode,
p.bondType ?? '',
p.partyName,
p.partyType,
p.panNo
[
p.address1,
p.address2,
p.address3
]
.filter((x) => !!x)
.join(', '),
p.city ?? '',
p.pincode ?? '',
p.phone ?? '',
p.fax ?? '',
p.bankType ?? '',
p.glCode ?? '',
p.rbiCode ?? '',
p.limit ?? null,
p.leiNo ?? '',
p.cpBankName ?? '',
p.cpBranchName ?? '',
p.cpAccountNo ?? '',
p.cpIfsc ?? '',
p.status ?? 'ACTIVE'
])
);
case 'script':
return one(
[
'Script Code',
'Script Name',
'Instrument',
'Bond Type',
'SLR',
'Script Desc',
'GL A/c No',
'ISIN',
'Face Value/Unit'
'Int Rate %',
'Issue Date',
'Maturity Date',
'Period (Y/M/D)',
'Face Value/Unit',
'Company Name',
'Corp Code'
],
[
14,
16,
12,
30,
12,
16,
16,
16
10,
14,
14,
14,
14,
24,
12
],
this.scripts.map((s) => [
s.scriptCode,
s.scriptName,
s.instrumentType,
s.isin,
s.faceValuePerUnit
])
this.scripts.map((s) => {
const p = periodBetween(s.issueDate, s.maturityDate);
return [
s.scriptCode,
s.instrumentType,
s.slrType ?? '',
s.scriptName,
s.glCode ?? '',
s.isin,
s.couponRate ?? null,
s.issueDate ?? '',
s.maturityDate ?? '',
s.issueDate && s.maturityDate ? `${p.years}/${p.months}/${p.days}` : '',
s.faceValuePerUnit,
s.companyName ?? '',
s.corpCode ?? ''
];
})
);
case 'subtype':
return one(
+448
View File
@@ -0,0 +1,448 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, forkJoin } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import {
DailyScriptRate,
HoldingStatus,
InstrumentType,
InvestmentHolding,
PARTY_MASTER,
PartyMaster,
SCRIPT_MASTER,
ScriptMaster,
SlrType
} from './investment-data';
/** Investment party as returned by /investment/parties. */
export interface InvestmentPartyDto {
id: number;
partyCode: string;
bondType: InstrumentType;
partyName: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
bankType?: string;
glAccountId: number;
glCode: string;
glName: string;
rbiCode?: string;
limit?: number;
leiNo?: string;
cpBankName?: string;
cpBranchName?: string;
cpAccountNo?: string;
cpIfsc?: string;
active: boolean;
}
/** Investment script as returned by /investment/scripts (dates are DD-MM-YYYY). */
export interface InvestmentScriptDto {
id: number;
scriptCode: string;
bondType: InstrumentType;
slrType?: SlrType;
scriptDesc: string;
glAccountId: number;
glCode: string;
glName: string;
isin?: string;
faceValuePerUnit: number;
intRate?: number;
issueDate?: string;
maturityDate?: string;
companyName?: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
corpCode?: string;
active: boolean;
}
/** Purchase entry (holding) as returned by /investment/purchases (dates are DD-MM-YYYY). */
export interface InvestmentPurchaseDto {
id: number;
dealNo: string;
certNo: string;
bondType: InstrumentType;
scriptId: number;
scriptCode: string;
scriptDesc: string;
isin?: string;
partyId: number;
partyCode: string;
partyName: string;
dealDate: string;
dealTime?: string;
settlementType: string;
settlementDate: string;
faceValue: number;
purchaseRate: number;
units: number;
balanceUnits: number;
brokerage?: number;
stampDuty?: number;
otherCharges?: number;
bookValue: number;
premiumDiscount?: number;
intRate?: number;
issueDate?: string;
maturityDate?: string;
lastIntDate?: string;
accruedDays?: number;
accruedInterest?: number;
settlementAmount: number;
ytm?: number;
category: string;
subType?: string;
investmentGlAccountId: number;
investmentGlCode: string;
investmentGlName: string;
crGlAccountId: number;
crGlCode: string;
crGlName: string;
otherDetails?: string;
status: string;
glTransactionId?: string;
settledOnDate?: string;
}
export interface InvestmentGlAccount {
id: number;
glCode: string;
name: string;
}
/**
* Backend calls for the Investment masters (Bank / Party Master and Script Master). Results are also copied into the
* shared PARTY_MASTER / SCRIPT_MASTER lists so screens that still read those lists (Purchase Entry) offer the saved
* masters.
*/
@Injectable({ providedIn: 'root' })
export class InvestmentService {
private http = inject(HttpClient);
// -------------------- Bank / Party Master --------------------
getParties(): Observable<InvestmentPartyDto[]> {
return this.http.get<InvestmentPartyDto[]>('/investment/parties');
}
createParty(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/parties', body);
}
updateParty(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/parties/${id}`, body);
}
deleteParty(id: number): Observable<unknown> {
return this.http.delete(`/investment/parties/${id}`);
}
// -------------------- Script Master --------------------
getScripts(): Observable<InvestmentScriptDto[]> {
return this.http.get<InvestmentScriptDto[]>('/investment/scripts');
}
createScript(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/scripts', body);
}
updateScript(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/scripts/${id}`, body);
}
// -------------------- Purchase Entry --------------------
getPurchases(): Observable<InvestmentPurchaseDto[]> {
return this.http.get<InvestmentPurchaseDto[]>('/investment/purchases');
}
/** Coupon and 31 Mar / 30 Sep provision lines of a holding up to maturity (calculated, not posted). */
/** Interest lines of all holdings that are due now or within the next few days. */
getDueInterest(): Observable<InvestmentInterestDueDto[]> {
return this.http.get<InvestmentInterestDueDto[]>('/investment/interest-postings/due');
}
/** Posts one interest line (coupon / provision) with its JV voucher; the voucher number comes back as resourceIdentifier. */
postInterest(purchaseId: number, body: object): Observable<{ resourceIdentifier?: string; transactionId?: string }> {
return this.http.post<{ resourceIdentifier?: string; transactionId?: string }>(
`/investment/purchases/${purchaseId}/interest-postings`,
body
);
}
getInterestSchedule(purchaseId: number, asOnDate?: string): Observable<InvestmentInterestScheduleDto> {
const params = asOnDate ? `?asOnDate=${encodeURIComponent(asOnDate)}` : '';
return this.http.get<InvestmentInterestScheduleDto>(
`/investment/purchases/${purchaseId}/interest-schedule${params}`
);
}
// -------------------- Daily Script Master --------------------
getDailyRates(scriptId?: number): Observable<InvestmentDailyRateDto[]> {
const params = scriptId ? `?scriptId=${scriptId}` : '';
return this.http.get<InvestmentDailyRateDto[]>(`/investment/daily-rates${params}`);
}
/** Newest Daily Script Master rate of a script on or before a date; null when none has been entered. */
getLatestDailyRate(scriptId: number, onDate: string): Observable<InvestmentDailyRateDto | null> {
return this.http.get<InvestmentDailyRateDto | null>(
`/investment/daily-rates/latest?scriptId=${scriptId}&onDate=${encodeURIComponent(onDate)}`
);
}
createDailyRate(body: object): Observable<{ resourceId: number }> {
return this.http.post<{ resourceId: number }>('/investment/daily-rates', body);
}
deleteDailyRate(rateId: number): Observable<unknown> {
return this.http.delete(`/investment/daily-rates/${rateId}`);
}
/** Settles a deal: posts it to GL dated its settlement date. Under maker-checker the response only says it awaits approval. */
settlePurchase(purchaseId: number): Observable<{ transactionId?: string; rollbackTransaction?: boolean }> {
return this.http.post<{ transactionId?: string; rollbackTransaction?: boolean }>(
`/investment/purchases/${purchaseId}/settle`,
{}
);
}
createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body);
}
deletePurchase(id: number): Observable<unknown> {
return this.http.delete(`/investment/purchases/${id}`);
}
// -------------------- GL accounts --------------------
/** Enabled ASSET detail GLs — the only kind an investment party / script can be booked to. */
getAssetGlAccounts(): Observable<InvestmentGlAccount[]> {
return this.http
.get<
Array<{ id: number; glCode: string; name: string; disabled: boolean }>
>('/glaccounts', { params: { type: '1', usage: '1', disabled: 'false' } })
.pipe(
map((list) =>
list
.map((g) => ({ id: g.id, glCode: g.glCode, name: g.name }))
.sort((a, b) => a.glCode.localeCompare(b.glCode))
)
);
}
/** Loads both masters from the backend and refreshes the shared lists other screens read. */
syncSharedMasters(): Observable<{ parties: PartyMaster[]; scripts: ScriptMaster[] }> {
return forkJoin({ parties: this.getParties(), scripts: this.getScripts() }).pipe(
map(({ parties, scripts }) => ({ parties: parties.map(toPartyMaster), scripts: scripts.map(toScriptMaster) })),
tap(({ parties, scripts }) => {
PARTY_MASTER.splice(0, PARTY_MASTER.length, ...parties);
SCRIPT_MASTER.splice(0, SCRIPT_MASTER.length, ...scripts);
})
);
}
}
export function toPartyMaster(p: InvestmentPartyDto): PartyMaster {
return {
id: p.id,
partyCode: p.partyCode,
partyName: p.partyName,
partyType: 'BANK',
bondType: p.bondType,
address1: p.address1,
address2: p.address2,
address3: p.address3,
city: p.city,
pincode: p.pincode,
phone: p.phone,
fax: p.fax,
bankType: p.bankType,
glAccountId: p.glAccountId,
glCode: p.glCode,
glName: p.glName,
rbiCode: p.rbiCode,
limit: p.limit ?? null,
leiNo: p.leiNo,
cpBankName: p.cpBankName,
cpBranchName: p.cpBranchName,
cpAccountNo: p.cpAccountNo,
cpIfsc: p.cpIfsc,
status: p.active ? 'ACTIVE' : 'INACTIVE'
};
}
export function toScriptMaster(s: InvestmentScriptDto): ScriptMaster {
return {
id: s.id,
scriptCode: s.scriptCode,
scriptName: s.scriptDesc,
instrumentType: s.bondType,
isin: s.isin ?? '',
faceValuePerUnit: s.faceValuePerUnit,
slrType: s.slrType,
glAccountId: s.glAccountId,
glCode: s.glCode,
glName: s.glName,
couponRate: s.intRate,
issueDate: s.issueDate,
maturityDate: s.maturityDate,
companyName: s.companyName,
address1: s.address1,
address2: s.address2,
address3: s.address3,
city: s.city,
pincode: s.pincode,
phone: s.phone,
fax: s.fax,
corpCode: s.corpCode,
active: s.active
};
}
/** A saved purchase as a row of the Holdings register. */
export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
return {
id: p.id,
dealNo: p.dealNo,
certNo: p.certNo,
instrumentType: p.bondType,
scriptCode: p.scriptCode,
scriptName: p.scriptDesc,
party: p.partyName,
partyCode: p.partyCode,
subType: p.subType ?? '',
faceValue: p.faceValue,
purchaseRate: p.purchaseRate,
units: p.balanceUnits,
purchaseDate: p.settlementDate,
maturityDate: p.maturityDate || p.dealDate,
interestRate: p.intRate ?? 0,
interestFreq: p.bondType === 'MUTUAL_FUND' ? 'CUMULATIVE' : 'HALF_YEARLY',
glCode: p.investmentGlCode,
glName: p.investmentGlName,
crToAc: p.crGlCode,
brokerCode: '',
status: p.status as HoldingStatus,
settlementDate: p.settlementDate,
glTransactionId: p.glTransactionId,
category: p.category,
bookValue: p.bookValue,
settlementAmount: p.settlementAmount
};
}
/** One line of a holding's interest schedule (see InvestmentInterestScheduleData in the backend). */
export interface InvestmentInterestRowDto {
type: 'COUPON' | 'PROVISION';
intDate: string;
fromDate: string;
toDate: string;
days: number;
grossInterest: number;
alreadyBooked: number;
amount: number;
due: boolean;
/** Posted to GL: its JV voucher and GL transaction. */
posted: boolean;
voucherNo?: string;
glTransactionId?: string;
postedOnDate?: string;
/** The next line to post and its date has come. */
postable: boolean;
/** Unposted and due within the reminder window (or past). */
dueSoon: boolean;
}
/** A holding's next interest line that is due (or due within the reminder window) — a task to post. */
export interface InvestmentInterestDueDto {
purchaseId: number;
dealNo: string;
certNo: string;
scriptCode: string;
scriptDesc: string;
lineType: 'COUPON' | 'PROVISION';
intDate: string;
grossInterest: number;
amount: number;
postable: boolean;
overdue: boolean;
}
export interface InvestmentInterestScheduleDto {
purchaseId: number;
dealNo: string;
certNo: string;
scriptCode: string;
scriptDesc: string;
faceValue: number;
unitValue: number;
balanceUnits: number;
purchaseRate: number;
bookValue: number;
intRate: number;
dealDate: string;
lastIntDate: string;
maturityDate: string;
purchasedAccruedInterest: number;
couponPerHalfYear: number;
asOnDate: string;
interestGlCode: string;
interestGlName: string;
bankGlAccountId: number;
bankGlCode: string;
bankGlName: string;
status: string;
rows: InvestmentInterestRowDto[];
}
/** A Daily Script Master row (see InvestmentDailyRateData in the backend). */
export interface InvestmentDailyRateDto {
id: number;
scriptId: number;
scriptCode: string;
scriptDesc: string;
bondType: string;
rateDate: string;
openRate: number | null;
highRate: number | null;
lowRate: number | null;
closeRate: number;
}
export function toDailyScriptRate(d: InvestmentDailyRateDto): DailyScriptRate {
return {
id: d.id,
rateDate: d.rateDate,
scriptCode: d.scriptCode,
scriptName: d.scriptDesc,
openRate: d.openRate,
closeRate: d.closeRate,
highRate: d.highRate,
lowRate: d.lowRate
};
}
/** The first validation / domain-rule message from a Fineract error response, for a toast. */
export function apiErrorMessage(err: unknown): string {
const e = err as HttpErrorResponse;
const body = e?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || e?.message || 'Request failed.';
}
@@ -7,27 +7,6 @@
-->
<form [formGroup]="loansAccountDetailsForm">
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>Category</mat-label>
<mat-select [value]="categoryFilter" (selectionChange)="categoryFilter = $event.value; onCategoryChange()">
<mat-option value="">All categories</mat-option>
@for (category of categories; track category.id) {
<mat-option [value]="category.id">{{ category.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Product type</mat-label>
<mat-select [value]="typeFilter" (selectionChange)="typeFilter = $event.value; applyProductFilters()">
<mat-option value="">All types</mat-option>
@for (type of typeOptions; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
@@ -56,8 +35,6 @@
</mat-form-field>
</div>
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
@if (loanProductSelected) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
@@ -96,15 +96,6 @@
>
Edit · Submit
</button>
<button
type="button"
class="lirr__icon-btn lirr__icon-btn--danger"
title="Delete draft"
aria-label="Delete draft"
(click)="deleteDraft(r, $event)"
>
<i class="fas fa-trash"></i>
</button>
</ng-container>
<ng-container *ngSwitchCase="'Active'">
<button
@@ -522,31 +522,3 @@
color: #3730a3;
}
}
/* Icon-only action on a row (delete draft). */
.lirr__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-left: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--danger {
color: #dc2626;
border-color: #fecaca;
&:hover {
background: #fef2f2;
}
}
}
@@ -10,8 +10,6 @@ import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service';
@@ -35,7 +33,6 @@ export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
registers: RateRegister[] = [];
active?: RateRegister;
@@ -74,22 +71,6 @@ export class LoanInterestRateRegisterComponent implements OnInit {
}
}
/** Deletes a draft (a version that was never submitted) after a confirmation. */
deleteDraft(register: RateRegister, event: Event): void {
event.stopPropagation();
const label = register.name || `draft from ${this.formatShort(register.effectiveFrom)}`;
this.dialog
.open(DeleteDialogComponent, { data: { deleteContext: label } })
.afterClosed()
.subscribe((response: any) => {
if (response?.delete) {
this.service.deleteDraft(register.id);
this.snack.open('Draft deleted', 'OK', { duration: 3000 });
this.reload();
}
});
}
/** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void {
event.stopPropagation();
@@ -52,13 +52,6 @@ export class RateRegisterService {
}
}
/** Throws a draft away. Only a local, never-submitted draft can go; server versions are never touched. */
deleteDraft(id: number): void {
this.localDrafts = this.localDrafts.filter((d) => !(d.id === id && d.status === 'Draft'));
this.cache = this.cache.filter((d) => !(d.id === id && d.status === 'Draft'));
this.saveDrafts();
}
/** Persist the current drafts (call after any edit the user should not lose). */
saveDrafts(): void {
try {
@@ -9,9 +9,7 @@
<div class="lp-container">
<div class="lp-header">
<i class="fas fa-hand-holding-usd lp-header__icon"></i>
<h2 class="lp-header__title">
{{ isEdit ? 'Edit Loan Product' : ('labels.heading.Create Loan Product' | translate) }}
</h2>
<h2 class="lp-header__title">{{ 'labels.heading.Create Loan Product' | translate }}</h2>
</div>
<mat-tab-group
@@ -32,7 +30,7 @@
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-coins lp-tab-icon"></i>
{{ 'labels.inputs.CURRENCY' | translate }}
@@ -45,16 +43,10 @@
</mat-tab>
@if (isAdvanceProduct) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
{{
advanceSubType === 'gold'
? 'SECURITY & GOLD'
: advanceSubType === 'warehouse'
? 'SECURITY & WAREHOUSE'
: 'SECURITY & LIEN'
}}
SECURITY &amp; LIEN
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-security-lien-step
@@ -65,7 +57,7 @@
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
@@ -77,7 +69,7 @@
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -90,7 +82,19 @@
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
@@ -101,7 +105,7 @@
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
@@ -114,31 +118,27 @@
}
@if (isLoanAccount) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; COLLATERAL
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-security-step
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-loan-security-step>
<mifosx-loan-product-loan-security-step></mifosx-loan-product-loan-security-step>
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
AMOUNT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-amount-tenure-step
[productSubType]="advanceSubType"
></mifosx-loan-product-loan-amount-tenure-step>
<mifosx-loan-product-loan-amount-tenure-step></mifosx-loan-product-loan-amount-tenure-step>
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-calendar-alt lp-tab-icon"></i>
REPAYMENT &amp; EMI
@@ -148,7 +148,7 @@
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -161,7 +161,19 @@
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-flag-checkered lp-tab-icon"></i>
PREPAYMENT &amp; CLOSURE
@@ -172,27 +184,12 @@
</mat-tab>
}
<!-- Common to both Advances A/C and Loans A/C (and visible even before a product type is chosen). -->
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<!--
OLD LOANS A/C PATH — commented out for now (replaced by the Loans A/C steps above).
Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
@if (!isAdvanceProduct) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
@@ -209,7 +206,7 @@
}
@if (isAdvancedPaymentStrategy) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-undo lp-tab-icon"></i>
{{ 'labels.inputs.INTEREST REFUND' | translate }}
@@ -222,7 +219,7 @@
</div>
</mat-tab>
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-exchange-alt lp-tab-icon"></i>
{{ 'labels.inputs.PAYMENT ALLOCATION' | translate }}
@@ -243,7 +240,7 @@
}
@if (!isAdvanceProduct) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
@@ -257,7 +254,7 @@
}
-->
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-receipt lp-tab-icon"></i>
{{ 'labels.inputs.CHARGES' | translate }}
@@ -273,7 +270,7 @@
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-clock lp-tab-icon"></i>
{{ 'labels.inputs.DEFERRED INCOME RECOGNITION' | translate }}
@@ -294,7 +291,7 @@
}
-->
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-book lp-tab-icon"></i>
{{ 'labels.inputs.ACCOUNTING' | translate }}
@@ -305,15 +302,12 @@
[accountingRuleData]="accountingRuleData"
[loanProductFormValid]="loanProductFormValid"
[deferredIncomeRecognition]="deferredIncomeRecognition"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')?.value"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')?.value"
[productGlAccountId]="productGlAccount"
></mifosx-loan-product-accounting-step>
</div>
</mat-tab>
@if (loanProductFormValid) {
<mat-tab [disabled]="!hasProductType">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-eye lp-tab-icon"></i>
{{ 'labels.inputs.PREVIEW' | translate }}
@@ -322,7 +316,7 @@
<mifosx-loan-product-preview-step
[loanProductsTemplate]="loanProductsTemplate"
[accountingRuleData]="accountingRuleData"
[loanProduct]="previewLoanProduct"
[loanProduct]="loanProduct"
(submitEvent)="submit()"
></mifosx-loan-product-preview-step>
</div>
@@ -330,25 +324,6 @@
}
</mat-tab-group>
@if (hasProductType && !loanProductFormValid) {
<div class="lp-incomplete">
<i class="fas fa-circle-info"></i>
<div>
<strong>The Review tab appears once every tab is complete.</strong>
Still to finish:
@for (item of incompleteTabs; track item.tab; let last = $last) {
<b>{{ item.tab }}</b>
@if (item.fields.length > 0) {
({{ item.fields.join(', ') }})
}
@if (!last) {
,
}
}
</div>
</div>
}
<div class="lp-action-bar">
<button mat-raised-button [routerLink]="['../']" class="lp-action-bar__cancel">
{{ 'labels.buttons.Cancel' | translate }}
@@ -361,12 +336,7 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button
mat-raised-button
color="primary"
[disabled]="!hasProductType"
(click)="selectedTabIndex = selectedTabIndex + 1"
>
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
{{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
@@ -374,7 +344,7 @@
@if (loanProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ isEdit ? 'Save changes' : ('labels.buttons.Submit' | translate) }}
{{ 'labels.buttons.Submit' | translate }}
</button>
}
</div>
@@ -90,27 +90,3 @@ $primary: #1353a0;
gap: 12px;
}
}
/* Tells the user which tabs still block the Review tab. */
.lp-incomplete {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 16px 0 0;
padding: 10px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
line-height: 1.5;
i {
margin-top: 3px;
color: #d97706;
}
b {
color: #92400e;
}
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { AfterViewInit, Component, OnInit, ViewChild, inject } from '@angular/core';
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */
@@ -84,7 +84,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
LoanProductPreviewStepComponent
]
})
export class CreateLoanProductComponent implements OnInit, AfterViewInit {
export class CreateLoanProductComponent implements OnInit {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
@@ -135,8 +135,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
loanProductAccountingStep: LoanProductAccountingStepComponent;
loanProductsTemplate: any;
/** True when this wizard edits a saved product (the edit route) instead of creating one. */
isEdit = false;
accountingRuleData: string[] = [];
itemsByDefault: any[] = [];
selectedTabIndex = 0;
@@ -160,382 +158,22 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
constructor() {
const loanProducts = this.loanProducts;
this.route.data.subscribe(
(data: { loanProductsTemplate: any; loanProductAndTemplate: any; configurations: any }) => {
// The edit route resolves the saved product together with the template options.
this.isEdit = !!data.loanProductAndTemplate;
this.loanProductsTemplate = data.loanProductAndTemplate ?? data.loanProductsTemplate;
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
assetAccountData.concat(liabilityAccountData);
this.route.data.subscribe((data: { loanProductsTemplate: any; configurations: any }) => {
this.loanProductsTemplate = data.loanProductsTemplate;
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
assetAccountData.concat(liabilityAccountData);
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
if (!this.isEdit) {
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
}
}
);
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
});
}
ngOnInit() {
this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
this.buildAdvancedPaymentAllocation();
// Advances and Loans A/C (EMI) products are filled into the curated tabs. A product made before the type was saved
// uses the old editor instead of an empty form.
const savedGroup = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
if (this.isEdit && savedGroup !== 'advances' && savedGroup !== 'loans') {
this.router.navigate(['../edit-legacy'], { relativeTo: this.route });
}
}
ngAfterViewInit(): void {
if (this.isEdit) {
this.populateWhenReady(0);
}
}
/** The tabs of the product's type appear after the Details step sets the type; fill them once they exist. */
private populateWhenReady(attempt: number): void {
const group = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
const ready =
group === 'advances'
? !!(
this.loanProductSecurityLienStep &&
this.loanProductAdvanceLimitTenureStep &&
this.loanProductAdvanceInterestStep &&
this.loanProductRenewalClosureStep &&
this.loanProductPenalNpaStep &&
(!this.isOverdraftFacility || this.loanProductChequeBookStep)
)
: group === 'loans'
? !!(
this.loanProductLoanSecurityStep &&
this.loanProductLoanAmountTenureStep &&
this.loanProductLoanRepaymentStep &&
this.loanProductLoanInterestStep &&
this.loanProductLoanPrepaymentStep &&
this.loanProductPenalNpaStep
)
: false;
if (!ready) {
if (attempt < 60) {
setTimeout(() => this.populateWhenReady(attempt + 1), 150);
}
return;
}
if (group === 'loans') {
this.populateLoans();
} else {
this.populateAdvance();
}
}
/** Reverse of assembleLoanProduct: the saved core fields and the extension record go back into the Loans tabs. */
private populateLoans(): void {
const p = this.loanProductsTemplate;
const e = p.advanceExtensions ?? {};
const repayment = this.loanProductLoanRepaymentStep;
const interest = this.loanProductLoanInterestStep;
const frequency = repayment.frequencyOptions.find((o) => o.fineractId === p.repaymentFrequencyType?.id)?.id;
const daysInYear = interest.daysInYearOptions.find((o) => o.fineractId === p.daysInYearType?.id)?.id;
const daysInMonth = interest.daysInMonthOptions.find((o) => o.fineractId === p.daysInMonthType?.id)?.id;
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
currencyCode: p.currency?.code,
digitsAfterDecimal: p.currency?.decimalPlaces
});
this.fill(this.loanProductLoanSecurityStep.loanProductLoanSecurityForm, {
securityType: e.securityType,
securityMandatory: e.securityMandatory,
margin: e.margin,
guarantorsRequired: e.guarantorsRequired,
coApplicant: e.coApplicant,
thirdPartySecurity: e.thirdPartySecurity,
legalOpinionRequired: e.legalOpinionRequired,
propertyInsuranceRequired: e.propertyInsuranceRequired,
registerCharge: e.registerCharge,
autoReleaseOnClosure: e.releaseSecurityOnClosure,
allowMultipleSecurities: e.allowMultipleSecurities,
allowThirdPartyDeposit: e.allowThirdPartyDeposit
});
this.fill(this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenureForm, {
minAmount: p.minPrincipal,
defaultAmount: p.principal,
maxAmount: p.maxPrincipal,
minTenure: p.minNumberOfRepayments,
defaultTenure: p.numberOfRepayments,
maxTenure: p.maxNumberOfRepayments,
minAge: e.minAge,
maxAgeAtMaturity: e.maxAgeAtMaturity,
minCibil: e.minCibil,
minMonthlyIncome: e.minMonthlyIncome,
maxFoir: e.maxFoir,
maxLoansPerCustomer: e.maxLoansPerCustomer,
memberRequired: e.memberRequired,
guarantorMustBeMember: e.guarantorMustBeMember,
incomeMultiple: e.incomeMultiple,
disbursementType: e.disbursementType,
// The tranche count only applies to a multiple disbursement; a single one leaves the field at its own default.
maxTranches: e.disbursementType === 'multiple' ? p.maxTrancheCount : undefined,
disbursementMode: e.disbursementMode,
undisbursedInterest: e.undisbursedInterest,
preEmiInterest: e.preEmiInterest
});
this.fill(repayment.loanProductLoanRepaymentForm, {
repaymentType: e.repaymentType,
repaidEvery: p.repaymentEvery,
frequency,
minDaysToFirstRepayment: p.minimumDaysBetweenDisbursalAndFirstRepayment,
emiDueDay: e.emiDueDay,
holidayRule: e.holidayRule,
moratoriumMonths: e.moratoriumMonths,
moratoriumInterest: e.moratoriumInterest,
variableInstallments: e.variableInstallments,
repaymentMode: e.repaymentMode,
linkedAccountType: e.linkedAccountType,
pdcCollection: e.pdcCollection,
mandateRequired: e.mandateRequired,
appropriationOrder: e.appropriationOrder,
advanceEmi: e.advanceEmi
});
this.fill(interest.loanProductLoanInterestForm, {
interestType: e.rateType,
registerParticular: e.registerParticular,
applyCibilAdjustment: e.applyCibilAdjustment,
spread: e.spread,
rateResolvedAt: e.rateResolvedAt,
minRate: p.minInterestRatePerPeriod,
defaultRate: p.interestRatePerPeriod,
maxRate: p.maxInterestRatePerPeriod,
calcMethod: e.interestCalcMethod,
daysInYear,
daysInMonth,
calcPeriod: e.calcPeriod,
interestStartsFrom: e.interestStartsFrom,
compounding: e.compounding,
benchmark: e.benchmark,
resetFrequency: e.resetFrequency,
resetChange: e.resetChange
});
this.fill(this.loanProductLoanPrepaymentStep.loanProductLoanPrepaymentForm, {
partPrepaymentAllowed: e.partPrepaymentAllowed,
minPartPayment: e.minPartPayment,
allowedOn: e.allowedOn,
partPaymentsPerYear: e.partPaymentsPerYear,
partPrepaymentMode: e.partPrepaymentMode,
foreclosureFloatingIndividual: e.foreclosureFloatingIndividual,
foreclosureFixedIndividual: e.foreclosureFixedIndividual,
foreclosureNonIndividual: e.foreclosureNonIndividual,
foreclosureTakeover: e.foreclosureTakeover,
lockInMonths: e.lockInMonths,
interestOnPreclosure: e.interestOnPreclosure,
foreclosureQuoteValidDays: e.foreclosureQuoteValidDays,
autoCloseOnZeroBalance: e.autoCloseOnZeroBalance,
nocLetter: e.nocLetter,
restructuring: e.restructuring,
topUpAllowed: e.topUpAllowed,
balanceTransferOutAllowed: e.balanceTransferOutAllowed
});
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
penalApplicable: e.penalApplicable,
penalRate: e.penalRate,
penalAppliedOn: e.penalAppliedOn,
penalGraceDays: e.penalGraceDays,
delinquencyBucketId: p.delinquencyBucket?.id,
npaDays: p.overdueDaysForNPA,
npaTrigger: e.npaTrigger,
interestAfterNpa: e.interestAfterNpa,
smaStartAfterMonths: e.smaStartAfterMonths,
inArrearsTolerance: p.inArrearsTolerance,
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears
});
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
undebitedInterestAccountId: e.undebitedInterestGlId
});
// Filling the currency empties the Charges list; put the saved charges back, or saving would detach them.
if (this.loanProductChargesStep) {
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
}
}
/** Sets the controls and marks them touched-by-user so a sub type default does not overwrite the saved value. */
private fill(form: UntypedFormGroup | undefined, values: Record<string, any>): void {
if (!form) {
return;
}
Object.keys(values).forEach((name) => {
const control = form.get(name);
const value = values[name];
if (control && value !== undefined && value !== null) {
// A YES / NO select is saved as true / false; give it the word the select understands.
const isYesNoSelect = control.value === 'YES' || control.value === 'NO';
control.setValue(isYesNoSelect && typeof value === 'boolean' ? (value ? 'YES' : 'NO') : value);
control.markAsDirty();
}
});
}
/** Reverse of assembleAdvanceProduct: the saved core fields and the extension record go back into the tabs. */
private populateAdvance(): void {
const p = this.loanProductsTemplate;
const e = p.advanceExtensions ?? {};
const split = (value: any) =>
value
? String(value)
.split('|')
.filter((v) => v !== '')
: undefined;
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
currencyCode: p.currency?.code,
digitsAfterDecimal: p.currency?.decimalPlaces
});
this.fill(this.loanProductSecurityLienStep.loanProductSecurityLienForm, {
securityType: e.securityType,
eligibleAmountBasis: e.eligibleAmountBasis,
margin: e.margin,
lienMarkingStage: e.lienMarkingStage,
eligibleDepositProducts: split(e.eligibleDepositProducts),
tenureNotExceedMaturity: e.lienTenureNotExceedMaturity,
autoReleaseOnClosure: e.lienAutoReleaseOnClosure,
allowMultipleDeposits: e.lienAllowMultipleDeposits,
adjustFromMaturity: e.lienAdjustFromMaturity,
allowThirdPartyDeposit: e.lienAllowThirdParty,
allowRenewalUnderLien: e.lienAllowRenewalUnderLien,
lienCertificateRequired: e.lienCertificateRequired,
lienCertificatePrefix: e.lienCertificatePrefix,
printOnDisbursement: e.printCertificateOnDisbursement,
goldMaxLtv: e.goldMaxLtv,
goldMinPurityCarat: e.goldMinPurityCarat,
goldRateSource: e.goldRateSource,
goldMaxPerGram: e.goldMaxPerGram,
goldMinNetWeight: e.goldMinNetWeight,
goldMaxNetWeightPerCustomer: e.goldMaxNetWeightPerCustomer,
goldAllowedItems: e.goldAllowedItems,
goldValuationCertificateRequired: e.goldValuationCertificateRequired,
goldMaxValuationPerAppraiser: e.goldMaxValuationPerAppraiser,
goldPacketPrefix: e.goldPacketPrefix,
goldRevalueOnNpa: e.goldRevalueOnNpa,
goldRevaluationChargeApplicable: e.goldRevaluationChargeApplicable,
insuranceRequired: e.insuranceRequired,
goldAuctionNoticeDays: e.goldAuctionNoticeDays,
goldPartialReleaseAllowed: e.goldPartialReleaseAllowed,
goldThirdPartyAllowed: e.goldThirdPartyAllowed,
goldDisbursementMode: e.goldDisbursementMode,
goldCashDisbursementLimit: e.goldCashDisbursementLimit,
goldLtvBreachAlert: e.goldLtvBreachAlert,
goldAuctionReservePct: e.goldAuctionReservePct,
wrMaxLtv: e.wrMaxLtv,
wrReceiptType: e.wrReceiptType,
wrTenureWithinReceiptValidity: e.wrTenureWithinReceiptValidity,
wrPledgeMarkedBeforeDisbursement: e.wrPledgeMarkedBeforeDisbursement,
wrCommodityGroups: split(e.wrCommodityGroups),
wrValuationPriceSource: e.wrValuationPriceSource,
wrMaxLoanPerQuintal: e.wrMaxLoanPerQuintal,
wrStockInspectionMonths: e.wrStockInspectionMonths,
wrLtvBreachAlert: e.wrLtvBreachAlert,
wrPartialReleaseAllowed: e.wrPartialReleaseAllowed,
wrDisposalNoticeDays: e.wrDisposalNoticeDays,
wrDisposalReservePct: e.wrDisposalReservePct
});
this.fill(this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenureForm, {
facilityType: e.facilityType,
minLimit: p.minPrincipal,
defaultLimit: p.principal,
maxLimit: p.maxPrincipal,
minTenure: p.minNumberOfRepayments,
defaultTenure: p.numberOfRepayments,
maxTenure: p.maxNumberOfRepayments,
repaymentType: e.repaymentType,
appropriationOrder: e.appropriationOrder,
debitBalanceAllow: e.debitBalanceAllow,
allowDebitTransAfterDueDate: e.allowDebitAfterDueDate,
branchLevelAccountType: e.branchLevelAccountType
});
this.fill(this.loanProductAdvanceInterestStep.loanProductAdvanceInterestForm, {
interestBasis: e.interestBasis,
registerParticular: e.registerParticular,
spread: e.spread,
calcMethod: e.interestCalcMethod,
interestChargedOn: e.interestChargedOn,
interestOnCreditBalance: e.interestOnCreditBalance,
postingFrequency: e.interestPostingFrequency,
postingDay: e.interestPostingDay
});
this.fill(this.loanProductRenewalClosureStep.loanProductRenewalClosureForm, {
renewalAllowed: e.renewalAllowed,
renewalPeriodMonths: e.renewalPeriodMonths,
renewalGraceDays: e.renewalGraceDays,
preclosureAllowed: e.preclosureAllowed,
lockInMonths: e.lockInMonths,
interestOnPreclosure: e.interestOnPreclosure
});
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
penalApplicable: e.penalApplicable,
penalRate: e.penalRate,
penalAppliedOn: e.penalAppliedOn,
penalGraceDays: e.penalGraceDays,
delinquencyBucketId: p.delinquencyBucket?.id,
npaDays: p.overdueDaysForNPA,
npaTrigger: e.npaTrigger,
interestAfterNpa: e.interestAfterNpa,
smaStartAfterMonths: e.smaStartAfterMonths,
stockStatementValidityDays: e.stockStatementValidityDays,
inArrearsTolerance: p.inArrearsTolerance,
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears,
keepStandardIfDepositCovers: e.npaKeepStandardIfDepositCovers,
npaOutOfOrderBasis: e.npaOutOfOrderBasis,
cropDuration: e.cropDuration
});
if (this.isOverdraftFacility && this.loanProductChequeBookStep) {
this.fill(this.loanProductChequeBookStep.loanProductChequeBookForm, {
chequeType: e.chequeType,
bookSizes: split(e.chequeBookSizes),
defaultBookSize: e.defaultChequeBookSize !== undefined ? String(e.defaultChequeBookSize) : undefined,
maxBooksOutstanding: e.chequeMaxBooksOutstanding,
firstBookFree: e.chequeFirstBookFree,
freeBooksPerYear: e.chequeFreeBooksPerYear,
namePrinted: e.chequeNamePrinted,
ppsThreshold: e.ppsThreshold,
ppsMandatoryAbove: e.ppsMandatoryAbove,
stopPaymentAllowed: e.chequeStopPaymentAllowed,
maintainStockRegister: e.chequeMaintainStockRegister,
allowWithdrawalSlip: e.chequeAllowWithdrawalSlip,
blockNpa: e.chequeBlockNpa,
blockLimitExpired: e.chequeBlockLimitExpired,
blockOverDp: e.chequeBlockOverDp,
blockFrozen: e.chequeBlockFrozen,
blockLienReleased: e.chequeBlockLienReleased
});
}
// The nine GLs: the core ones are filled by the Accounting step from the saved mappings; the suspense GL is
// kept on the extension record.
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
undebitedInterestAccountId: e.undebitedInterestGlId
});
// Filling the currency above empties the Charges list (a new currency invalidates it); put the saved charges back,
// or saving the edit would detach every charge from the product.
if (this.loanProductChargesStep) {
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
}
}
get loanProductDetailsForm() {
@@ -667,12 +305,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
return this.loanProductAccountingStep.loanProductAccountingForm;
}
/** True once the Details step has both a product type and a sub type; every other tab stays locked until then. */
get hasProductType(): boolean {
const details = this.loanProductDetailsForm;
return !!details?.get('productTypeGroup')?.value && !!details?.get('productTypeSubGroup')?.value;
}
/** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
@@ -684,14 +316,14 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
}
get totalTabs(): number {
let tabs = 5; // details, currency, penal & npa, charges, accounting (always)
let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, renewal & closure
tabs += 4;
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5;
if (this.isOverdraftFacility) tabs += 1; // cheque book
} else if (this.isLoanAccount) {
// security & collateral, amount & tenure, repayment & emi, interest, prepayment & closure
tabs += 5;
// security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
tabs += 6;
}
// Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
// else {
@@ -702,114 +334,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
return tabs;
}
/**
* The tabs that still block the Review tab, each with the fields to fix. The Review tab only appears once every
* tab is valid, and a tab that was never opened or a missing field is otherwise invisible to the user.
*/
get incompleteTabs(): { tab: string; fields: string[] }[] {
const humanize = (name: string) =>
name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/Id$/, '')
.replace(/^./, (ch) => ch.toUpperCase());
const tabs: [string, any][] = [
[
'Details',
this.loanProductDetailsForm
],
[
'Currency',
this.loanProductCurrencyForm
]
];
if (this.isAdvanceProduct) {
tabs.push(
[
'Security & Lien',
this.loanProductSecurityLienStep?.loanProductSecurityLienForm
],
[
'Limit & Tenure',
this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm
],
[
'Interest',
this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm
],
[
'Renewal & Closure',
this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm
]
);
if (this.isOverdraftFacility) {
tabs.push([
'Cheque Book',
this.loanProductChequeBookStep?.loanProductChequeBookForm
]);
}
} else if (this.isLoanAccount) {
tabs.push(
[
'Security & Collateral',
this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm
],
[
'Amount & Tenure',
this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm
],
[
'Repayment & EMI',
this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm
],
[
'Interest',
this.loanProductLoanInterestStep?.loanProductLoanInterestForm
],
[
'Prepayment & Closure',
this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm
]
);
}
tabs.push(
[
'Penal & NPA',
this.loanProductPenalNpaStep?.loanProductPenalNpaForm
],
[
'Accounting',
this.loanProductAccountingStep?.loanProductAccountingForm
]
);
return tabs
.filter(
([
,
form
]) => !form || form.invalid
)
.map(
([
tab,
form
]) => ({
tab,
fields: form
? Object.keys(form.controls)
.filter((key) => form.controls[key].invalid)
.slice(0, 4)
.map(humanize)
: []
})
);
}
get loanProductFormValid() {
const base =
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductAccountingForm.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid;
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) {
@@ -818,6 +345,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
);
@@ -831,6 +359,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
!!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
!!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
!!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
);
}
@@ -846,33 +375,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
// return base && loansValid;
}
private previewKey = '';
private previewProduct: any = {};
/**
* The product the Review tab shows. It is rebuilt only when a form value changes, so the summary gets a stable object
* (it fills in currency, schedule type and so on on the object it receives). Submit still uses the fresh `loanProduct`.
*/
get previewLoanProduct(): any {
const key = JSON.stringify(
Object.keys(this)
.filter((name) => /Step$/.test(name))
.map((name) => {
const step = (this as any)[name];
return step
? Object.keys(step)
.filter((prop) => /Form$/.test(prop))
.map((prop) => step[prop]?.getRawValue?.())
: null;
})
);
if (key !== this.previewKey) {
this.previewKey = key;
this.previewProduct = this.loanProduct;
}
return this.previewProduct;
}
get loanProduct() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
@@ -1033,14 +535,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
interestAfterNpa: pn.interestAfterNpa,
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent. "Out of order" (CC / OD only) and the
// deposit-cover rule do not apply to a term loan, so neither is sent.
cropDuration: pn.cropDuration || undefined,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
securityMandatory: sec.securityMandatory,
guarantorsRequired: num(sec.guarantorsRequired),
coApplicant: sec.coApplicant,
@@ -1057,9 +554,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
minMonthlyIncome: num(at.minMonthlyIncome),
maxFoir: num(at.maxFoir),
maxLoansPerCustomer: num(at.maxLoansPerCustomer),
memberRequired: at.memberRequired,
guarantorMustBeMember: at.guarantorMustBeMember,
incomeMultiple: num(at.incomeMultiple),
disbursementType: at.disbursementType,
disbursementMode: at.disbursementMode,
undisbursedInterest: at.undisbursedInterest,
@@ -1097,6 +591,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
nocLetter: pp.nocLetter,
restructuring: pp.restructuring,
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
topUpAllowed: pp.topUpAllowed,
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
};
@@ -1118,9 +613,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
const lien = this.loanProductSecurityLienStep.isLienSubType;
const gold = this.loanProductSecurityLienStep.isGold ? sec.gold : null;
const wr = this.loanProductSecurityLienStep.isWarehouse ? sec.warehouse : null;
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
@@ -1196,62 +688,10 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
ltv: sec.ltv,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
// Lien settings exist only for a deposit / policy sub type; a pledged one (Gold ...) sends none of them.
...(lien
? {
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement
}
: {}),
...(gold
? {
goldMaxLtv: num(gold.maxLtv),
goldMinPurityCarat: num(gold.minPurityCarat),
goldRateSource: gold.rateSource,
goldMaxPerGram: num(gold.maxPerGram),
goldMinNetWeight: num(gold.minNetWeight),
goldMaxNetWeightPerCustomer: num(gold.maxNetWeightPerCustomer),
goldAllowedItems: gold.allowedItems,
goldAppraiserRequired: true,
goldValuationCertificateRequired: gold.valuationCertificateRequired === 'YES',
goldMaxValuationPerAppraiser: num(gold.maxValuationPerAppraiser),
goldPacketPrefix: gold.packetPrefix || undefined,
goldRevalueOnNpa: gold.revalueOnNpa,
goldRevaluationChargeApplicable: gold.revaluationChargeApplicable,
insuranceRequired: gold.insuranceRequired === 'YES',
goldAuctionNoticeDays: num(gold.auctionNoticeDays),
goldPartialReleaseAllowed: gold.partialReleaseAllowed,
goldThirdPartyAllowed: gold.thirdPartyAllowed,
goldDisbursementMode: gold.disbursementMode,
goldCashDisbursementLimit:
gold.disbursementMode === 'account_credit_cash_limit' ? num(gold.cashDisbursementLimit) : undefined,
goldLtvBreachAlert: num(gold.ltvBreachAlert),
goldAuctionReservePct: num(gold.auctionReservePct),
goldRedeemBeforeAuction: true
}
: {}),
...(wr
? {
wrMaxLtv: num(wr.maxLtv),
wrReceiptType: wr.receiptType,
wrWdraRegistered: true,
wrAssayCertificateRequired: true,
wrTenureWithinReceiptValidity: wr.tenureWithinReceiptValidity,
wrPledgeMarkedBeforeDisbursement: wr.pledgeMarkedBeforeDisbursement,
wrCommodityGroups: (wr.commodityGroups ?? []).join('|'),
wrValuationPriceSource: wr.valuationPriceSource,
wrMaxLoanPerQuintal: num(wr.maxLoanPerQuintal),
wrStockInspectionMonths: num(wr.stockInspectionMonths),
insuranceRequired: wr.insuranceRequired === 'YES',
wrLtvBreachAlert: num(wr.ltvBreachAlert),
wrPartialReleaseAllowed: wr.partialReleaseAllowed,
wrDisposalNoticeDays: num(wr.disposalNoticeDays),
wrDisposalReservePct: num(wr.disposalReservePct),
wrRedeemBeforeDisposal: true
}
: {}),
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement,
interestBasis: intr.interestBasis,
registerParticular: intr.registerParticular || undefined,
spread: num(intr.spread),
@@ -1262,14 +702,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
stockStatementValidityDays: num(pn.stockStatementValidityDays),
interestAfterNpa: pn.interestAfterNpa,
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent.
npaOutOfOrderBasis: pn.npaOutOfOrderBasis || undefined,
cropDuration: pn.cropDuration || undefined,
renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays),
@@ -1283,18 +716,14 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
// ---- remaining wizard fields ----
// security & lien extras (deposit list joined; rule checkboxes), only for a deposit / policy sub type
...(lien
? {
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien
}
: {}),
// security & lien extras (deposit list joined; rule checkboxes)
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
// limit & tenure extras
repaymentType: lt.repaymentType,
appropriationOrder: lt.appropriationOrder,
@@ -1307,13 +736,15 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
interestPostingDay: intr.postingDay,
// NPA upgrade rules
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
...(lien ? { npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers } : {}),
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
// cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
@@ -1340,19 +771,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
delete loanProduct['daysInYearCustomStrategy'];
}
if (this.isEdit) {
this.productsService.updateLoanProduct(this.loanProductsTemplate.id, loanProduct).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Loan product update request submitted successfully for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
return;
}
this.productsService.createLoanProduct(loanProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
@@ -1,79 +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/.
*/
/**
* A saved Advances / Loans product keeps most of its settings in an extension record (`advanceExtensions`), under the
* same names the edit form submits. Putting those next to the core fields gives every submitted field a current value
* to compare against, so a pending edit shows only what really changed. Core fields win on a name clash.
*
* The product read leaves out a core number that is zero, while the edit form always sends it, so that one is filled in
* as 0 rather than reading as "newly set" on every edit.
*/
export function withExtensions(product: any): any {
if (!product) {
return product;
}
const merged = product.advanceExtensions ? { ...product.advanceExtensions, ...product } : { ...product };
if (merged.inArrearsTolerance === undefined || merged.inArrearsTolerance === null) {
merged.inArrearsTolerance = 0;
}
// Same for the arrears grace: a Loans A/C (EMI) product counts arrears from day one, and the read leaves a zero out.
if (merged.graceOnArrearsAgeing === undefined || merged.graceOnArrearsAgeing === null) {
merged.graceOnArrearsAgeing = 0;
}
return merged;
}
/** Core accounting mapping name on the product read -> the payload name the Review reader uses. */
const GL_MAPPING_KEYS: Record<string, string> = {
loanPortfolioAccount: 'loanPortfolioAccountId',
fundSourceAccount: 'fundSourceAccountId',
interestOnLoanAccount: 'interestOnLoanAccountId',
incomeFromFeeAccount: 'incomeFromFeeAccountId',
incomeFromPenaltyAccount: 'incomeFromPenaltyAccountId',
writeOffAccount: 'writeOffAccountId',
receivableInterestAccount: 'receivableInterestAccountId',
overpaymentLiabilityAccount: 'overpaymentLiabilityAccountId'
};
/** A date the API sends as [2026, 10, 15] shown as 15-10-2026. */
function dateText(value: any): string | undefined {
if (!Array.isArray(value) || value.length < 3) {
return value ?? undefined;
}
const [
year,
month,
day
] = value;
return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`;
}
/**
* A saved Advances product as the read-only Review page needs it: the flat field names the create wizard submits
* (core fields plus the extension record), the GL mappings as ids with a matching option list so each shows by name,
* and the attached charges as one readable line. Pure; the product object is not changed.
*/
export function reviewProductOf(product: any): { product: any; template: any } {
const flat: any = { ...withExtensions(product) };
const accounts: any[] = [];
Object.keys(GL_MAPPING_KEYS).forEach((name) => {
const account = product?.accountingMappings?.[name];
if (account?.id !== undefined) {
flat[GL_MAPPING_KEYS[name]] = account.id;
accounts.push({ id: account.id, glCode: account.glCode, name: account.name });
}
});
flat.currencyCode = product?.currency?.code;
flat.digitsAfterDecimal = product?.currency?.decimalPlaces;
flat.startDate = dateText(product?.startDate);
flat.closeDate = dateText(product?.closeDate);
flat.delinquencyBucketId = undefined;
flat.chargesSummary = (product?.charges ?? []).map((charge: any) => `${charge.name} (${charge.amount})`).join(', ');
return { product: flat, template: { accountingMappingOptions: { assetAccountOptions: accounts } } };
}
@@ -214,242 +214,3 @@ $gray-900: #202124;
grid-template-columns: 1fr;
}
}
/* Penal / SMA / NPA / ECL cards: each gets its own accent so they read as separate blocks. */
.pn-card {
border-left-width: 4px;
&--penal {
border-left-color: #0ea5e9;
}
&--sma {
border-left-color: #f59e0b;
}
&--npa {
border-left-color: #dc2626;
}
&--ecl {
border-left-color: #6366f1;
}
}
.pn-stages {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 16px;
}
.pn-stage {
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
&--0 {
background: #fef9c3;
color: #854d0e;
}
&--1 {
background: #ffedd5;
color: #9a3412;
}
&--2 {
background: #fee2e2;
color: #991b1b;
}
}
/* ECL card: collapsed by default, grouped, plain wording. */
.pn-ecl-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 0;
background: #f8fafc;
color: #1e293b;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
&__title {
display: inline-flex;
align-items: center;
gap: 10px;
white-space: nowrap;
i {
color: #6366f1;
}
}
&__tag {
padding: 2px 8px;
border-radius: 999px;
background: #e0e7ff;
color: #3730a3;
font-size: 11px;
font-weight: 600;
}
&__summary {
flex: 1;
min-width: 0;
overflow: hidden;
color: #64748b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.pn-ecl-body {
padding: 4px 16px 16px;
}
.pn-ecl-intro {
margin: 8px 0 16px;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.pn-ecl-group {
margin-bottom: 20px;
h4 {
margin: 0 0 12px;
color: #1e293b;
font-size: 14px;
font-weight: 600;
}
}
.pn-ecl-row {
margin-top: 12px;
}
.pn-ecl-check {
display: flex;
align-items: center;
min-height: 40px;
}
/* Eligibility & Documents tab. */
.ed-body {
padding: 16px;
}
.ed-count {
margin-left: auto;
padding: 2px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 600;
}
.ed-rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.ed-rule {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
&__text {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #1e293b;
font-size: 14px;
font-weight: 600;
}
small {
color: #64748b;
font-size: 12px;
}
}
}
.ed-group {
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
&__title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: #475569;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
i {
color: #6366f1;
}
}
}
.ed-docs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.ed-doc {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #334155;
font-size: 14px;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--on {
border-color: #a5b4fc;
background: #eef2ff;
color: #1e1b4b;
}
}
@@ -38,12 +38,18 @@
<div class="lpa-group">
<div class="lpa-group__header">
<h4>{{ section.title }}</h4>
<button
type="button"
class="lpa-add-other-btn"
(click)="addOtherGlMapping(section.otherMappingsControlName)"
>
+ Add Other
</button>
</div>
<div class="lpa-list">
@for (row of section.rows; track row.key) {
@if (
basicControls.has(row.controlName) &&
(!row.accrualOnly || isAccrualAccounting() || basicControls.has(row.controlName)) &&
(!row.accrualOnly || isAccrualAccounting()) &&
(!row.deferredOnly || isDeferredOnly()) &&
(!row.deferredCapitalizationOnly || isDeferredCapitalizationOnly()) &&
(!row.deferredBuyDownOnly || isDeferredBuyDownOnly()) &&
@@ -72,6 +78,334 @@
}
}
</div>
<!-- Other GL Mappings -->
<div class="lpa-list" [formArrayName]="section.otherMappingsControlName">
@for (
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
track $index;
let i = $index
) {
<div class="lpa-other-row-wrap">
<div class="lpa-row lpa-row--other" [formGroupName]="i">
<div class="lpa-row__meta lpa-row__meta--other">
<span
class="lpa-pill"
[class.lpa-pill--green]="section.pillClass === 'green'"
[class.lpa-pill--amber]="section.pillClass === 'amber'"
[class.lpa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<input
class="lpa-row__name lpa-row__name--other-account"
formControlName="accountName"
placeholder="Enter account name..."
type="text"
/>
</div>
<div class="lpa-row__actions lpa-row__actions--other">
<mat-form-field class="lpa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>GL Code</mat-label>
<mat-select
[formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
>
@for (glAccount of section.otherAccountOptions; track glAccount.id) {
<mat-option [value]="glAccount.id">
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<button
type="button"
class="lpa-remove-btn lpa-remove-btn--side"
(click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
>
<i class="fas fa-times"></i>
</button>
</div>
}
</div>
</div>
}
<!-- Advanced Accounting Rules Section -->
@if (loanProductAccountingForm.value.accountingRule >= 2 && loanProductAccountingForm.value.accountingRule <= 4) {
<div class="lpa-group">
<div class="lpa-group__header">
<h4>Advanced Accounting Rules</h4>
</div>
<div class="lpa-checkbox-row">
<mat-checkbox formControlName="advancedAccountingRules">{{
'labels.heading.Advanced Accounting Rules' | translate
}}</mat-checkbox>
</div>
@if (loanProductAccountingForm.value.advancedAccountingRules) {
<!-- Payment Fund Sources -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PaymentFundSource', paymentChannelToFundSourceMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (paymentChannelToFundSourceMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="paymentChannelToFundSourceMappings.value">
<ng-container matColumnDef="paymentTypeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="fundSourceAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.fundSourceAccountId | find: assetAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Fees to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('FeesIncome', feeToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (feeToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="feeToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.incomeAccountId | find: incomeAndLiabilityAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Penalties to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PenaltyIncome', penaltyToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (penaltyToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="penaltyToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Charge-off Reasons to Expense -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Charge-off reasons to Expense accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
[disabled]="!allowAddChargeOffReasonExpense"
(click)="add('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (chargeOffReasonToExpenseAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="chargeOffReasonToExpenseAccountMappings.value">
<ng-container matColumnDef="chargeOffReasonCodeValueId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Charge-off reason' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{
chargeOffReasonExpense.chargeOffReasonCodeValueId
| find: chargeOffReasonOptions : 'id' : 'name'
}}
</td>
</ng-container>
<ng-container matColumnDef="expenseAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Expense Account' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{ chargeOffReasonExpense.expenseAccountId | find: expenseAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="chargeOffReasonExpenseDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: chargeOffReasonExpenseDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Advanced Mapping Rule Components -->
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'BuydownFeeClassificationToIncome'"
[formArray]="buydownfeeClassificationToIncomeAccountMappings"
[textHeading]="'Buydown Fee classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="buydownFeeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'CapitalizedIncomeClassificationToIncome'"
[formArray]="capitalizedIncomeClassificationToIncomeAccountMappings"
[textHeading]="'Capitalized Income classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="capitalizedIncomeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'WriteOff Reason'"
[formType]="'WriteOffReasonToExpense'"
[formArray]="writeOffReasonsToExpenseMappings"
[textHeading]="'WriteOff reasons to Expense accounts'"
[expenseAccountData]="expenseAccountData"
[accountingMappingOptions]="writeOffReasonOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
}
</div>
}
</div>
@@ -104,23 +104,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
@Input() accountingRuleData: any;
@Input() loanProductFormValid: boolean;
@Input() deferredIncomeRecognition: DeferredIncomeRecognition;
/** The product's category / type and its portfolio GL, from the Details step; they drive the default GL mapping. */
@Input() productTypeGroup: string | null = null;
@Input() productTypeSubGroup: string | null = null;
@Input() productGlAccountId: number | string | null = null;
/** The mappings every product needs; the rest default from these and sit behind "Show all mappings". */
readonly basicControls = new Set<string>([
'loanPortfolioAccountId',
'fundSourceAccountId',
'interestOnLoanAccountId',
'incomeFromFeeAccountId',
'incomeFromPenaltyAccountId',
'writeOffAccountId',
'receivableInterestAccountId',
'overpaymentLiabilityAccountId',
'undebitedInterestAccountId'
]);
public loanProductAccountingForm!: UntypedFormGroup;
@@ -168,73 +151,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
if (this.loanProductAccountingForm) {
this.setDeferredIncomeRecognitionControls();
}
if (changes['productTypeGroup'] || changes['productTypeSubGroup'] || changes['productGlAccountId']) {
this.applyDefaultMapping();
}
}
/** The id of the GL account with this GL code among all the accounting options, or null. */
private glIdByCode(code: string): number | null {
const all = [
...(this.assetAccountData ?? []),
...(this.incomeAccountData ?? []),
...(this.expenseAccountData ?? []),
...(this.liabilityAccountData ?? [])
];
return all.find((account: any) => String(account.glCode) === code)?.id ?? null;
}
/**
* Pre-fills the GL mappings from the product type: the portfolio GL is the product's own GL, interest income follows
* the loan term (Advances short, Loans medium, Housing long), the rest are the bank's shared GLs. A mapping the
* officer has already changed is left alone.
*/
applyDefaultMapping(): void {
// A saved product keeps its own mappings; one saved without any still gets the defaults.
const hasSavedMappings = !!this.loanProductsTemplate?.id && !!this.loanProductsTemplate?.accountingMappings;
if (!this.loanProductAccountingForm || !this.assetAccountData || hasSavedMappings) {
return;
}
const isAdvance = this.productTypeGroup === 'advances';
const interestCode = isAdvance ? '400301' : this.productTypeSubGroup === 'housing' ? '400303' : '400302';
const cash = this.glIdByCode('100101');
const interest = this.glIdByCode(interestCode);
const fee = this.glIdByCode('400106') ?? this.glIdByCode('400101');
const penal = this.glIdByCode('400304') ?? this.glIdByCode('400101');
const writeOff = this.glIdByCode('500503');
const receivable = this.glIdByCode('100601');
const overpayment = this.glIdByCode('200907');
const suspense = this.glIdByCode('200908');
const defaults: Record<string, number | string | null> = {
loanPortfolioAccountId: this.productGlAccountId || null,
fundSourceAccountId: cash,
transfersInSuspenseAccountId: cash,
interestOnLoanAccountId: interest,
incomeFromFeeAccountId: fee,
incomeFromPenaltyAccountId: penal,
incomeFromRecoveryAccountId: interest,
writeOffAccountId: writeOff,
goodwillCreditAccountId: writeOff,
chargeOffFraudExpenseAccountId: writeOff,
chargeOffExpenseAccountId: writeOff,
incomeFromChargeOffPenaltyAccountId: penal,
incomeFromChargeOffFeesAccountId: fee,
incomeFromChargeOffInterestAccountId: interest,
incomeFromGoodwillCreditInterestAccountId: interest,
incomeFromGoodwillCreditFeesAccountId: fee,
incomeFromGoodwillCreditPenaltyAccountId: penal,
receivableInterestAccountId: receivable,
receivableFeeAccountId: receivable,
receivablePenaltyAccountId: receivable,
overpaymentLiabilityAccountId: overpayment,
undebitedInterestAccountId: suspense
};
Object.keys(defaults).forEach((name) => {
const control = this.loanProductAccountingForm.get(name);
if (control && !control.dirty && defaults[name]) {
control.setValue(defaults[name]);
}
});
}
ngOnInit() {
@@ -261,7 +177,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
this.loanProductAccountingForm.get('accountingRule').disable();
this.buildAccountingSectionConfigs();
this.applyDefaultMapping();
const accountingMappings = this.loanProductsTemplate.accountingMappings;
this.setDeferredIncomeRecognitionControls();
@@ -362,33 +277,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId',
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId'
];
// CBS-specific mappings the backend does not accept yet: optional here so a product can be created without
// them (empty values are dropped from the payload in LoanProducts.buildPayload).
const optionalCbsFields = [
// derived by the backend from the nine mapped GLs (LoanGlMappingDefaults)
'transfersInSuspenseAccountId',
'incomeFromRecoveryAccountId',
'goodwillCreditAccountId',
'chargeOffFraudExpenseAccountId',
'chargeOffExpenseAccountId',
'incomeFromChargeOffPenaltyAccountId',
'incomeFromChargeOffFeesAccountId',
'incomeFromChargeOffInterestAccountId',
'incomeFromGoodwillCreditInterestAccountId',
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
@@ -401,10 +289,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
];
commonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(
field,
new UntypedFormControl('', optionalCbsFields.includes(field) ? null : Validators.required)
);
this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required));
}
});
@@ -682,20 +567,8 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
} = this.loanProductAccountingForm.getRawValue();
const otherGlMappings = this.getOtherGlMappings();
// Only the nine mapped GLs are sent. The other Fineract placeholders (recovery, goodwill credit, charge-off ...)
// are derived by the backend. Interest Suspense has no core mapping, so it is kept on the satellite record.
const payload: any = {};
this.basicControls.forEach((name) => {
if (name !== 'undebitedInterestAccountId' && loanProductAccounting[name]) {
payload[name] = loanProductAccounting[name];
}
});
if (loanProductAccounting.undebitedInterestAccountId) {
payload.undebitedInterestGlId = loanProductAccounting.undebitedInterestAccountId;
}
return {
accountingRule: loanProductAccounting.accountingRule,
...payload,
...loanProductAccounting,
...(otherGlMappings ? { otherGlMappings } : {})
};
}
@@ -811,7 +684,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
inputLabel: 'Interest Receivable',
controlName: 'receivableInterestAccountId',
options: this.assetAccountData,
required: false,
required: true,
accrualOnly: true
},
{
@@ -61,12 +61,12 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (tenureBand; as band) {
<mat-hint>{{ band.label }} policy: {{ band.min }}–{{ band.max }} months only</mat-hint>
@if (isFdTenureLocked) {
<mat-hint>FD policy: 12–15 months only</mat-hint>
}
<mat-error>
@if (tenureBand; as band) {
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
@@ -75,31 +75,22 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (tenureBand; as band) {
<mat-hint>Between {{ band.min }} and {{ band.max }}</mat-hint>
}
@if (tenureBand; as band) {
<mat-error
>Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months</mat-error
>
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (tenureBand; as band) {
<mat-hint>
{{ band.label }} policy: max {{ band.max }} months
@if (band.label === 'FD') {
(also capped at deposit maturity)
}
</mat-hint>
@if (isFdTenureLocked) {
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
} @else {
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
}
<mat-error>
@if (tenureBand; as band) {
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
@@ -110,28 +110,21 @@ export class LoanProductAdvanceLimitTenureStepComponent {
return this.loanProductAdvanceLimitTenureForm.getRawValue();
}
/** Bank-policy tenure band per sub type: FD 12 to 15 months, a bullet Gold loan 1 to 12. Others stay open. */
private readonly tenureBands: Record<string, { label: string; min: number; max: number; defaultTenure: number }> = {
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12 },
gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 },
warehouse: { label: 'Warehouse', min: 1, max: 12, defaultTenure: 6 }
};
/** The policy band in force for the chosen sub type, or null when the sub type is open. */
get tenureBand(): { label: string; min: number; max: number; defaultTenure: number } | null {
return this.tenureBands[this._productSubType] ?? null;
/** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */
get isFdTenureLocked(): boolean {
return this._productSubType === 'fd';
}
get tenureMin(): number {
return this.tenureBand?.min ?? 1;
return this.isFdTenureLocked ? 12 : 1;
}
get tenureMax(): number | null {
return this.tenureBand?.max ?? null;
return this.isFdTenureLocked ? 15 : null;
}
/**
* FD (12–15 months) and Gold (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
* FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1).
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
* match the selected product.
*/
@@ -143,11 +136,10 @@ export class LoanProductAdvanceLimitTenureStepComponent {
const min = form.get('minTenure');
const def = form.get('defaultTenure');
const max = form.get('maxTenure');
const band = this.tenureBand;
if (band) {
if (this._productSubType === 'fd') {
const bounds = [
Validators.min(band.min),
Validators.max(band.max)
Validators.min(12),
Validators.max(15)
];
min.setValidators([
Validators.required,
@@ -158,9 +150,9 @@ export class LoanProductAdvanceLimitTenureStepComponent {
Validators.required,
...bounds
]);
min.setValue(band.min, { emitEvent: false });
def.setValue(band.defaultTenure, { emitEvent: false });
max.setValue(band.max, { emitEvent: false });
min.setValue(12, { emitEvent: false });
def.setValue(12, { emitEvent: false });
max.setValue(15, { emitEvent: false });
} else {
min.setValidators([
Validators.required,
@@ -42,6 +42,10 @@
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@@ -62,6 +66,10 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
@@ -62,9 +62,11 @@ export class LoanProductChequeBookStepComponent {
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
@@ -105,29 +105,6 @@ export class LoanProductDetailsStepComponent implements OnInit {
startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
});
// A saved Advances / Loans product comes back with its type in the extension record. Show it filled and
// lock it: changing the type would change every other tab.
const ext = this.loanProductsTemplate?.advanceExtensions;
if (this.isExistingProduct && ext?.productTypeGroup) {
this.loanProductDetailsForm.patchValue({
productTypeGroup: ext.productTypeGroup,
productTypeSubGroup: ext.productTypeSubGroup,
glAccount: ext.glAccount ?? '',
loanProductCategory: ext.loanProductCategory ?? this.loanProductDetailsForm.value.loanProductCategory,
prioritySector: ext.prioritySector ?? this.loanProductDetailsForm.value.prioritySector,
cibilAccountType: ext.cibilAccountType ?? this.loanProductDetailsForm.value.cibilAccountType,
kfsEnabled: this.yesNo(ext.kfsEnabled, this.loanProductDetailsForm.value.kfsEnabled),
aprEnabled: this.yesNo(ext.aprEnabled, this.loanProductDetailsForm.value.aprEnabled)
});
this.loanProductDetailsForm.get('productTypeGroup').disable({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').disable({ emitEvent: false });
}
}
/** A saved YES / NO choice comes back as true / false. */
private yesNo(saved: any, fallback: any): any {
return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback);
}
/** True when editing a saved product; see ngOnInit. */
@@ -248,77 +225,11 @@ export class LoanProductDetailsStepComponent implements OnInit {
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
}
this.applyCategoryForSubType(subGroupId);
});
// The category sets the Priority Sector and CIBIL defaults. A change the officer makes here reaches this
// subscription; the sub type's own default (above) calls applyCategoryDefaults directly.
this.loanProductDetailsForm.get('loanProductCategory')?.valueChanges.subscribe((category: string) => {
this.applyCategoryDefaults(category);
});
}
/** Sub type -> the category that normally fits it. Sub types not listed leave the category as it is. */
private readonly categoryBySubType: Record<string, string> = {
fd: 'advances_deposits',
rd: 'advances_deposits',
dds: 'advances_deposits',
lic: 'advances_deposits',
gold: 'gold_loan',
warehouse: 'agriculture',
// Loans A/C (EMI)
term: 'other',
personal: 'personal',
housing: 'housing',
vehicle: 'personal',
education: 'personal',
business: 'msme',
gold_emi: 'gold_loan'
};
/**
* Category -> Priority Sector and CIBIL account type that normally go with it (RBI sector classification).
* Defaults only: the officer can still pick another value for either.
*/
private readonly defaultsByCategory: Record<string, { prioritySector: string; cibilAccountType: string }> = {
agriculture: { prioritySector: 'yes', cibilAccountType: 'other' },
msme: { prioritySector: 'yes', cibilAccountType: 'business_loan' },
personal: { prioritySector: 'no', cibilAccountType: 'other' },
housing: { prioritySector: 'no', cibilAccountType: 'other' },
advances_deposits: { prioritySector: 'no', cibilAccountType: 'loan_bank_deposits' },
gold_loan: { prioritySector: 'no', cibilAccountType: 'gold_loan' },
other: { prioritySector: 'no', cibilAccountType: 'other' }
};
/** Picking a sub type sets its usual category (unless the officer chose one), and the category's defaults follow. */
private applyCategoryForSubType(subGroupId: string): void {
const category = this.categoryBySubType[subGroupId];
if (!category) {
return;
}
const control = this.loanProductDetailsForm.get('loanProductCategory');
if (control && !control.dirty) {
control.setValue(category, { emitEvent: false });
this.applyCategoryDefaults(category);
}
}
/** Sets Priority Sector and CIBIL type for a category, leaving alone any the officer has already chosen. */
private applyCategoryDefaults(category: string): void {
const fit = this.defaultsByCategory[category];
if (!fit) {
return;
}
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
const control = this.loanProductDetailsForm.get(name);
if (control && !control.dirty) {
control.setValue(fit[name], { emitEvent: false });
}
});
}
get loanProductDetails() {
const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue();
const loanProductDetailsFormData = this.loanProductDetailsForm.value;
const prevStartDate: Date = this.loanProductDetailsForm.value.startDate;
const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate;
const dateFormat = this.settingsService.dateFormat;
@@ -1,60 +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/.
-->
<form [formGroup]="loanProductEligibilityDocsForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-check"></i>
<h3>Borrower Eligibility</h3>
</div>
<div class="ed-body">
<div class="ed-rules">
<label class="ed-rule">
<mat-checkbox formControlName="memberRequired"></mat-checkbox>
<span class="ed-rule__text">
<strong>Borrower must be a member</strong>
<small>Only members of the bank can take this product</small>
</span>
</label>
<label class="ed-rule">
<mat-checkbox formControlName="guarantorMustBeMember"></mat-checkbox>
<span class="ed-rule__text">
<strong>Guarantors must be members</strong>
<small>Applies when the product asks for guarantors</small>
</span>
</label>
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-file-lines"></i>
<h3>Required Documents</h3>
<span class="ed-count">{{ selectedCount }} of {{ documentOptions.length }} selected</span>
</div>
<div class="ed-body">
@for (group of documentGroups; track group.title) {
<div class="ed-group">
<div class="ed-group__title"><i class="fas {{ group.icon }}"></i>{{ group.title }}</div>
<div class="ed-docs">
@for (doc of documentsOf(group); track doc.id) {
<label class="ed-doc" [class.ed-doc--on]="isSelected(doc.id)">
<mat-checkbox
[checked]="isSelected(doc.id)"
(change)="toggleDocument(doc.id, $event.checked)"
></mat-checkbox>
<span>{{ doc.label }}</span>
</label>
}
</div>
</div>
}
</div>
</div>
</form>
@@ -1,163 +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, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Borrower eligibility and document checklist, common to Advances A/C and Loans A/C. Age, CIBIL, income and
* FOIR already live on the Loans amount/tenure step, so only the rules both categories share are held here.
* Sent as satellite params (LoanProductAdvanceExtService.ADVANCE_PARAMETERS).
*/
@Component({
selector: 'mifosx-loan-product-eligibility-docs-step',
templateUrl: './loan-product-eligibility-docs-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductEligibilityDocsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() productTypeGroup: UntypedFormControl;
loanProductEligibilityDocsForm: UntypedFormGroup;
readonly documentOptions = [
{ id: 'KYC', label: 'KYC (ID proof)' },
{ id: 'ADDRESS_PROOF', label: 'Address proof' },
{ id: 'INCOME_PROOF', label: 'Income proof / salary certificate' },
{ id: 'ITR', label: 'Income-tax returns (3 years)' },
{ id: 'DEMAND_PROMISSORY_NOTE', label: 'Demand promissory note' },
{ id: 'LOAN_AGREEMENT', label: 'Loan agreement' },
{ id: 'GUARANTEE_DEED', label: 'Guarantor agreement' },
{ id: 'SALARY_DEDUCTION_AUTHORITY', label: 'Employer salary-deduction authority' },
{ id: 'LETTER_OF_LIEN', label: 'Letter of lien and set-off' },
{ id: 'LETTER_OF_CONTINUITY', label: 'Letter of continuity' },
{ id: 'HYPOTHECATION_AGREEMENT', label: 'Hypothecation agreement' },
{ id: 'TITLE_DEED', label: 'Title deed / registry' },
{ id: 'VALUATION_REPORT', label: 'Valuation report' },
{ id: 'STOCK_STATEMENT', label: 'Stock statement' },
{ id: 'INSURANCE_POLICY', label: 'Insurance policy (joint name)' },
{ id: 'QUOTATION', label: 'Dealer quotation / estimate' }
];
/** The checklist grouped the way an officer thinks about the papers. Every document sits in exactly one group. */
readonly documentGroups: { title: string; icon: string; ids: string[] }[] = [
{ title: 'Identity and address', icon: 'fa-id-card', ids: [
'KYC',
'ADDRESS_PROOF'
] },
{ title: 'Income', icon: 'fa-money-bill-wave', ids: [
'INCOME_PROOF',
'ITR',
'SALARY_DEDUCTION_AUTHORITY'
] },
{
title: 'Agreements',
icon: 'fa-file-signature',
ids: [
'DEMAND_PROMISSORY_NOTE',
'LOAN_AGREEMENT',
'GUARANTEE_DEED',
'HYPOTHECATION_AGREEMENT'
]
},
{ title: 'Lien and continuity', icon: 'fa-lock', ids: [
'LETTER_OF_LIEN',
'LETTER_OF_CONTINUITY'
] },
{
title: 'Security papers',
icon: 'fa-shield-halved',
ids: [
'TITLE_DEED',
'VALUATION_REPORT',
'STOCK_STATEMENT',
'INSURANCE_POLICY',
'QUOTATION'
]
}
];
documentsOf(group: { ids: string[] }): { id: string; label: string }[] {
return this.documentOptions.filter((doc) => group.ids.includes(doc.id));
}
get selectedCount(): number {
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).length;
}
private readonly defaultDocuments: Record<string, string[]> = {
advances: [
'KYC',
'DEMAND_PROMISSORY_NOTE',
'LETTER_OF_LIEN',
'LETTER_OF_CONTINUITY'
],
loans: [
'KYC',
'ADDRESS_PROOF',
'INCOME_PROOF',
'LOAN_AGREEMENT',
'GUARANTEE_DEED'
]
};
constructor() {
this.loanProductEligibilityDocsForm = this.formBuilder.group({
memberRequired: [true],
guarantorMustBeMember: [true],
requiredDocuments: [[]]
});
}
ngOnInit() {
if (this.productTypeGroup) {
this.applyDefaultDocuments(this.productTypeGroup.value);
this.productTypeGroup.valueChanges.subscribe((group: string) => this.applyDefaultDocuments(group));
}
}
/** Pre-ticks the usual checklist for the category until the user edits it. */
private applyDefaultDocuments(group: string): void {
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
if (control && !control.dirty && this.defaultDocuments[group]) {
control.setValue([...this.defaultDocuments[group]]);
}
}
isSelected(id: string): boolean {
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).includes(id);
}
toggleDocument(id: string, checked: boolean): void {
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
const current: string[] = control?.value ?? [];
control?.setValue(
checked
? [
...new Set([
...current,
id
])
]
: current.filter((d) => d !== id)
);
control?.markAsDirty();
}
get loanProductEligibilityDocs() {
const value = this.loanProductEligibilityDocsForm.getRawValue();
return { ...value, requiredDocuments: (value.requiredDocuments ?? []).join(',') };
}
}
@@ -44,38 +44,18 @@
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" [min]="band?.minTenure ?? 1" required />
@if (band; as b) {
<mat-hint>{{ b.label }}: {{ b.minTenure }}–{{ b.maxTenure }} months</mat-hint>
}
<mat-error>
@if (band; as b) {
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
</mat-error>
<input matInput type="number" formControlName="minTenure" min="1" required />
<mat-error>Minimum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" [min]="band?.minTenure ?? 1" />
@if (band; as b) {
<mat-error
>Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months</mat-error
>
}
<input matInput type="number" formControlName="defaultTenure" min="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" [min]="band?.minTenure ?? 1" required />
<input matInput type="number" formControlName="maxTenure" min="1" required />
<mat-hint>Age at maturity cap comes from Borrower Eligibility below</mat-hint>
<mat-error>
@if (band; as b) {
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
</mat-error>
<mat-error>Maximum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
@@ -110,17 +90,6 @@
<mat-label>Maximum Loans per Customer</mat-label>
<input matInput type="number" formControlName="maxLoansPerCustomer" min="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Income Multiple (× net monthly salary)</mat-label>
<input matInput type="number" formControlName="incomeMultiple" min="0" step="0.5" />
<mat-hint>Loan up to this many times the net salary. Policy: personal loan 10×</mat-hint>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="memberRequired">Applicant must be a member of the bank</mat-checkbox>
<mat-checkbox formControlName="guarantorMustBeMember">Guarantors must be members of the bank</mat-checkbox>
</div>
</div>
</div>
@@ -6,70 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Tenure band (months) and borrower age limits for a Loans A/C sub type, taken from the bank's loan policy: short term
* up to 15 months, medium 15 to 84, long above 84, and never more than 120. Housing runs 7 to 10 years, education is
* repaid in 84 equal instalments, vehicle 3 to 7 years, personal 3 to 5 years.
*/
interface LoanBand {
label: string;
minTenure: number;
defaultTenure: number;
maxTenure: number;
minAge: number;
maxAgeAtMaturity?: number;
/** Loan = this many times the net monthly salary (policy: personal loan 10x). */
incomeMultiple?: number;
/** The guarantors must themselves be members of the bank (policy: personal loan). */
guarantorMustBeMember?: boolean;
}
const LOAN_BANDS: Record<string, LoanBand> = {
personal: {
label: 'Personal Loan',
minTenure: 6,
defaultTenure: 36,
maxTenure: 60,
minAge: 21,
maxAgeAtMaturity: 60,
incomeMultiple: 10,
guarantorMustBeMember: true
},
housing: {
label: 'Housing Loan',
minTenure: 12,
defaultTenure: 84,
maxTenure: 120,
minAge: 21,
maxAgeAtMaturity: 70
},
vehicle: { label: 'Vehicle Loan', minTenure: 12, defaultTenure: 60, maxTenure: 84, minAge: 21, maxAgeAtMaturity: 65 },
education: { label: 'Education Loan', minTenure: 12, defaultTenure: 84, maxTenure: 84, minAge: 18 },
business: {
label: 'Business / MSME Loan',
minTenure: 12,
defaultTenure: 60,
maxTenure: 120,
minAge: 21,
maxAgeAtMaturity: 65
},
gold_emi: {
label: 'Gold EMI Loan',
minTenure: 6,
defaultTenure: 12,
maxTenure: 36,
minAge: 21,
maxAgeAtMaturity: 70
},
term: { label: 'Term Loan', minTenure: 12, defaultTenure: 60, maxTenure: 120, minAge: 21, maxAgeAtMaturity: 65 }
};
/**
* Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure,
* borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product
@@ -91,27 +33,6 @@ export class LoanProductLoanAmountTenureStepComponent {
loanProductLoanAmountTenureForm: UntypedFormGroup;
private _productSubType = '';
/** The sub type picked on the Details step: it sets the tenure band the three tenure fields must stay inside. */
@Input()
set productSubType(value: string) {
const next = value || '';
if (next === this._productSubType) {
return;
}
this._productSubType = next;
this.applyBand();
}
get productSubType(): string {
return this._productSubType;
}
/** The band in force for the chosen sub type, or null for a sub type without one. */
get band(): LoanBand | null {
return LOAN_BANDS[this._productSubType] ?? null;
}
readonly disbursementModeOptions = [
{ id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' },
{ id: 'transfer', label: 'Transfer to A/C only' },
@@ -164,13 +85,6 @@ export class LoanProductLoanAmountTenureStepComponent {
minMonthlyIncome: [''],
maxFoir: [50],
maxLoansPerCustomer: [1],
// Policy: loans go to members, so the applicant must be a member; a personal loan also needs member guarantors.
memberRequired: [true],
guarantorMustBeMember: [false],
incomeMultiple: [
'',
Validators.min(0)
],
disbursementType: ['single'],
maxTranches: [
2,
@@ -182,68 +96,6 @@ export class LoanProductLoanAmountTenureStepComponent {
});
}
/** Sets the tenure limits and the age defaults for the sub type; a value the officer has typed is left alone. */
private applyBand(): void {
const form = this.loanProductLoanAmountTenureForm;
const band = this.band;
if (!form || !band) {
return;
}
const tenureControls: [string, number][] = [
[
'minTenure',
band.minTenure
],
[
'defaultTenure',
band.defaultTenure
],
[
'maxTenure',
band.maxTenure
]
];
const bounds = [
Validators.min(band.minTenure),
Validators.max(band.maxTenure)
];
tenureControls.forEach(
([
name,
value
]) => {
const control = form.get(name);
control.setValidators(
name === 'defaultTenure' ? bounds : [
Validators.required,
...bounds
]
);
if (!control.dirty) {
control.setValue(value, { emitEvent: false });
}
control.updateValueAndValidity({ emitEvent: false });
}
);
const incomeMultiple = form.get('incomeMultiple');
if (!incomeMultiple.dirty) {
incomeMultiple.setValue(band.incomeMultiple ?? '', { emitEvent: false });
}
const guarantorMember = form.get('guarantorMustBeMember');
if (!guarantorMember.dirty) {
guarantorMember.setValue(band.guarantorMustBeMember ?? false, { emitEvent: false });
}
const minAge = form.get('minAge');
if (!minAge.dirty) {
minAge.setValue(band.minAge, { emitEvent: false });
}
const maxAge = form.get('maxAgeAtMaturity');
// A sub type without its own limit goes back to the general 70, not the previous sub type's value.
if (!maxAge.dirty) {
maxAge.setValue(band.maxAgeAtMaturity ?? 70, { emitEvent: false });
}
}
get isMultiTranche(): boolean {
return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple';
}
@@ -63,16 +63,8 @@
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · floating rate (%)</mat-label>
<input
matInput
type="number"
formControlName="foreclosureFloatingIndividual"
min="0"
max="0"
step="0.01"
readonly
/>
<mat-hint>Nil as per RBI. Cannot be changed</mat-hint>
<input matInput type="number" formControlName="foreclosureFloatingIndividual" min="0" max="100" step="0.01" />
<mat-hint>Nil as per RBI</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · fixed rate (%)</mat-label>
@@ -140,6 +132,7 @@
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="releaseCollateralOnClosure">Release collateral and lien on closure</mat-checkbox>
<mat-checkbox formControlName="topUpAllowed">Top-up loan on the same account</mat-checkbox>
<mat-checkbox formControlName="balanceTransferOutAllowed">Allow balance transfer (takeover) out</mat-checkbox>
</div>
@@ -77,13 +77,9 @@ export class LoanProductLoanPrepaymentStepComponent {
allowedOn: ['emi_date'],
partPaymentsPerYear: ['unlimited'],
partPrepaymentMode: ['reduce_tenure'],
// RBI: no foreclosure / pre-payment charge on a floating-rate loan to an individual, so this stays at nil.
foreclosureFloatingIndividual: [
0,
[
Validators.min(0),
Validators.max(0)
]
percent
],
foreclosureFixedIndividual: [
2,
@@ -109,6 +105,7 @@ export class LoanProductLoanPrepaymentStepComponent {
autoCloseOnZeroBalance: ['YES'],
nocLetter: ['auto'],
restructuring: ['approval'],
releaseCollateralOnClosure: [true],
topUpAllowed: [false],
balanceTransferOutAllowed: [true]
});
@@ -49,7 +49,6 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Days to First Repayment</mat-label>
<input matInput type="number" formControlName="minDaysToFirstRepayment" min="0" />
<mat-hint>Policy: instalments start from the 2nd month after the loan month</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>EMI Due Day</mat-label>
@@ -70,7 +69,6 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Moratorium / Grace (months)</mat-label>
<input matInput type="number" formControlName="moratoriumMonths" min="0" />
<mat-hint>Interest and EMI fall due only after the moratorium ends (RBI)</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest during Moratorium</mat-label>
@@ -100,16 +98,14 @@
}
</mat-select>
</mat-form-field>
@if (isAutoDebit) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PDC Collection</mat-label>
<mat-select formControlName="pdcCollection">
@@ -119,13 +115,11 @@
</mat-select>
</mat-form-field>
</div>
@if (isAutoDebit) {
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
}
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
@@ -28,17 +28,17 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRadioButton
]
})
export class LoanProductLoanRepaymentStepComponent implements OnInit {
export class LoanProductLoanRepaymentStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanRepaymentForm: UntypedFormGroup;
// Bullet / Step-up / Balloon live on the Advances A/C "Limit & Tenure" step instead — a Loans A/C
// (EMI) product always repays on a fixed instalment schedule, so only these three belong here.
readonly repaymentTypeOptions = [
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' },
{ id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' },
{ id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' }
];
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
@@ -50,7 +50,7 @@ export class LoanProductLoanRepaymentStepComponent implements OnInit {
readonly emiDueDayOptions = [
{ id: 'disbursement_day', label: 'Same day as disbursement' },
{ id: 'fixed_day', label: 'Fixed day of the month (day set on each account)' }
{ id: 'fixed_day', label: 'Fixed day of month (5th)' }
];
readonly holidayRuleOptions = [
@@ -114,52 +114,16 @@ export class LoanProductLoanRepaymentStepComponent implements OnInit {
],
moratoriumInterest: ['pre_emi'],
variableInstallments: [false],
// Loan policy: cheques for the instalments are taken together in advance (quarterly), so cheques are the default.
repaymentMode: ['pdc'],
repaymentMode: ['nach'],
linkedAccountType: ['savings_current'],
pdcCollection: ['mandatory'],
mandateRequired: [false],
mandateRequired: [true],
appropriationOrder: ['charges_penal_interest_principal'],
advanceEmi: ['adjust_next']
});
}
ngOnInit(): void {
// The mode decides how the EMI is collected, so it sets the cheque and mandate defaults that go with it.
this.loanProductLoanRepaymentForm
.get('repaymentMode')
.valueChanges.subscribe((mode: string) => this.applyMode(mode));
}
private applyMode(mode: string): void {
const defaults: Record<string, { pdcCollection: string; mandateRequired: boolean }> = {
nach: { pdcCollection: 'optional', mandateRequired: true },
pdc: { pdcCollection: 'mandatory', mandateRequired: false },
cash: { pdcCollection: 'none', mandateRequired: false }
};
const fit = defaults[mode];
if (!fit) {
return;
}
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
const control = this.loanProductLoanRepaymentForm.get(name);
if (control && !control.dirty) {
control.setValue(fit[name], { emitEvent: false });
}
});
}
/** Auto-debit needs a linked account and a registered mandate; the other modes do not. */
get isAutoDebit(): boolean {
return this.loanProductLoanRepaymentForm.get('repaymentMode').value === 'nach';
}
get loanProductLoanRepayment() {
const value = this.loanProductLoanRepaymentForm.getRawValue();
// A mandate only exists for auto-debit; never save it as required for cheques or cash.
if (!this.isAutoDebit) {
value.mandateRequired = false;
}
return value;
return this.loanProductLoanRepaymentForm.getRawValue();
}
}
@@ -28,9 +28,6 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
@if (marginHint) {
<mat-hint>{{ marginHint }}</mat-hint>
}
<mat-error>Margin must be between <strong>0 and 100</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
@@ -41,6 +38,40 @@
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table"></i>
<h3>Collateral Rules by Security Type</h3>
<span class="adv-section__hint">Which security this product may accept</span>
</div>
<table class="adv-table">
<thead>
<tr>
<th>Security</th>
<th>Allowed</th>
<th>Max LTV %</th>
<th>Valuation</th>
<th>Insurance</th>
<th>Charge / lien</th>
</tr>
</thead>
<tbody>
@for (rule of collateralRules; track rule.security) {
<tr>
<td>{{ rule.security }}</td>
<td [class.adv-table__yes]="rule.allowed" [class.adv-table__no]="!rule.allowed">
{{ rule.allowed ? 'Yes' : 'No' }}
</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.ltv }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.valuation }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.insurance }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.charge }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-plus"></i>
@@ -59,16 +90,14 @@
}
</mat-select>
</mat-form-field>
@if (isSecured) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@@ -78,15 +107,18 @@
<h3>Conditions</h3>
</div>
<div class="adv-subsection">
@if (shownConditions.length > 0) {
<div class="adv-checks">
@for (rule of shownConditions; track rule.control) {
<mat-checkbox [formControlName]="rule.control">{{ rule.label }}</mat-checkbox>
}
</div>
} @else {
<div class="adv-info">This loan is unsecured, so it carries no security conditions.</div>
}
<div class="adv-checks">
<mat-checkbox formControlName="legalOpinionRequired">
Legal opinion / title search mandatory before sanction
</mat-checkbox>
<mat-checkbox formControlName="propertyInsuranceRequired">
Property insurance mandatory for the whole tenure
</mat-checkbox>
<mat-checkbox formControlName="registerCharge">Register charge (CERSAI) before disbursement</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">Release security automatically on closure</mat-checkbox>
<mat-checkbox formControlName="allowMultipleSecurities">Allow multiple securities for one loan</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">Allow third-party deposit as security</mat-checkbox>
</div>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
@@ -6,157 +6,16 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface SecurityProfile {
securityType: string;
securityMandatory: 'yes' | 'no';
margin: number;
guarantorsRequired: number;
coApplicant: string;
thirdPartySecurity: string;
}
/** One condition a sub type's security carries: the checkbox it controls, its wording, and whether it starts ticked. */
interface ConditionRule {
control: string;
label: string;
defaultOn: boolean;
}
/** Every condition checkbox the form holds. One not listed for a sub type does not apply to it and is saved as off. */
const CONDITION_CONTROLS = [
'legalOpinionRequired',
'propertyInsuranceRequired',
'registerCharge',
'autoReleaseOnClosure',
'allowMultipleSecurities',
'allowThirdPartyDeposit'
];
/** Immovable property (Housing, Business, Term): title check, property bima, CERSAI charge. */
const PROPERTY_CONDITIONS: ConditionRule[] = [
{ control: 'legalOpinionRequired', label: 'Legal opinion / title search mandatory before sanction', defaultOn: true },
{ control: 'propertyInsuranceRequired', label: 'Property insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'registerCharge', label: 'Register charge (CERSAI) before disbursement', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Release the mortgage automatically on closure', defaultOn: true },
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false }
];
/** Vehicle: the vehicle is hypothecated, insured, and the lien is noted on the RC book. */
const VEHICLE_CONDITIONS: ConditionRule[] = [
{ control: 'propertyInsuranceRequired', label: 'Vehicle insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'registerCharge', label: 'Note the hypothecation on the RC book before disbursement', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Release the hypothecation automatically on closure', defaultOn: true }
];
/** Gold EMI: the gold is pledged and insured, and handed back on closure. No registered charge. */
const GOLD_CONDITIONS: ConditionRule[] = [
{ control: 'propertyInsuranceRequired', label: 'Gold insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Return the gold automatically on closure', defaultOn: true }
];
/** Wording used when a sub type has no list of its own but the officer made security mandatory. */
const GENERIC_CONDITIONS: ConditionRule[] = [
{
control: 'legalOpinionRequired',
label: 'Legal opinion / title search mandatory before sanction',
defaultOn: false
},
{
control: 'propertyInsuranceRequired',
label: 'Security insurance mandatory for the whole tenure',
defaultOn: false
},
{ control: 'registerCharge', label: 'Register the charge on the security before disbursement', defaultOn: false },
{ control: 'autoReleaseOnClosure', label: 'Release the security automatically on closure', defaultOn: false },
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false },
{ control: 'allowThirdPartyDeposit', label: 'Allow third-party deposit as security', defaultOn: false }
];
/** Personal and Education are unsecured, so they carry no security conditions unless security is made mandatory. */
const CONDITIONS_BY_SUB_TYPE: Record<string, ConditionRule[]> = {
housing: PROPERTY_CONDITIONS,
business: PROPERTY_CONDITIONS,
term: PROPERTY_CONDITIONS,
vehicle: VEHICLE_CONDITIONS,
gold_emi: GOLD_CONDITIONS,
personal: [],
education: PROPERTY_CONDITIONS
};
/** Sensible defaults by Loans A/C sub type — the officer can still override every field. */
const SECURITY_PROFILES: Record<string, SecurityProfile> = {
// Policy: a personal loan takes two guarantors who are bank members.
personal: {
securityType: 'None (unsecured)',
securityMandatory: 'no',
margin: 0,
guarantorsRequired: 2,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
// Policy: 90% of the expense is lent (the student bears 10%), against a house / plot / NSC worth double the loan,
// with the parent or guardian applying jointly.
education: {
securityType: 'Property / NSC (double the loan value)',
securityMandatory: 'yes',
margin: 10,
guarantorsRequired: 1,
coApplicant: 'always',
thirdPartySecurity: 'consent'
},
housing: {
securityType: 'Immovable property (mortgage)',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
},
vehicle: {
securityType: 'Vehicle (hypothecation)',
securityMandatory: 'yes',
margin: 20,
guarantorsRequired: 0,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
business: {
securityType: 'Immovable property / business assets',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
},
gold_emi: {
securityType: 'Gold ornaments (pledge)',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 0,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
term: {
securityType: 'Immovable property (mortgage)',
securityMandatory: 'yes',
margin: 20,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
}
};
/**
* Security & Collateral step for a Loans A/C (EMI) product (step 3).
*
* Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
* limit. Defaults adapt to the chosen sub type (e.g. Personal/Education default to unsecured,
* Housing/Vehicle/Gold default to their usual collateral) but every field stays editable.
* limit. UI-only for now: not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-loan-security-step',
@@ -167,46 +26,11 @@ const SECURITY_PROFILES: Record<string, SecurityProfile> = {
MatCheckbox
]
})
export class LoanProductLoanSecurityStepComponent implements OnInit {
export class LoanProductLoanSecurityStepComponent {
private formBuilder = inject(UntypedFormBuilder);
@Input() productTypeSubGroup: UntypedFormControl;
loanProductLoanSecurityForm: UntypedFormGroup;
/** What the bank's loan policy says about the margin for a sub type, shown under the Margin field. */
private readonly marginHints: Record<string, string> = {
housing: 'Policy: house construction 25%, flat / house purchase 20%, repair or renovation 10%',
vehicle: 'Policy: new vehicle 20–25%, old vehicle 50%',
education: 'Policy: the student bears 10% of the expense',
business: 'Policy: machinery 25% (old machinery 40–50%)',
personal: 'Unsecured: no margin'
};
get marginHint(): string {
return this.marginHints[this.productTypeSubGroup?.value] ?? '';
}
/** The conditions that apply to the chosen sub type; the template draws one checkbox for each. */
applicableConditions: ConditionRule[] = [];
/**
* The conditions drawn on screen: nothing while the loan is unsecured; the sub type's own list when it has one; and
* every condition (all off) when the officer makes security mandatory on a sub type that has no list of its own,
* so the officer can always set them.
*/
get shownConditions(): ConditionRule[] {
if (!this.isSecured) {
return [];
}
return this.applicableConditions.length > 0 ? this.applicableConditions : GENERIC_CONDITIONS;
}
/** True when sanction needs security; an unsecured product shows no security conditions. */
get isSecured(): boolean {
return this.loanProductLoanSecurityForm.get('securityMandatory').value === 'yes';
}
readonly securityMandatoryOptions = [
{ id: 'yes', label: 'Yes — no sanction without security' },
{ id: 'no', label: 'No — unsecured' }
@@ -223,6 +47,44 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
{ id: 'no', label: 'Not allowed' }
];
/** Reference table: which security a loan on this product may accept. */
readonly collateralRules = [
{
security: 'Residential property',
allowed: true,
ltv: '80',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Commercial property',
allowed: true,
ltv: '70',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Fixed / Recurring Deposit',
allowed: true,
ltv: '90',
valuation: 'Not needed',
insurance: 'Not needed',
charge: 'Lien marked'
},
{
security: 'LIC policy',
allowed: true,
ltv: '90',
valuation: 'Surrender value',
insurance: 'Not needed',
charge: 'Assignment'
},
{ security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' },
{ security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }
];
constructor() {
this.loanProductLoanSecurityForm = this.formBuilder.group({
securityType: ['Immovable property (mortgage)'],
@@ -250,49 +112,6 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
});
}
ngOnInit(): void {
this.applySubGroup(this.productTypeSubGroup?.value);
// Keep the security defaults in step with the sub type chosen on the Details step.
this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
}
private applySubGroup(subGroup: string): void {
const profile = SECURITY_PROFILES[subGroup];
if (!profile) {
return;
}
this.loanProductLoanSecurityForm.get('securityType').setValue(profile.securityType, { emitEvent: false });
this.loanProductLoanSecurityForm.get('securityMandatory').setValue(profile.securityMandatory, { emitEvent: false });
if (!this.loanProductLoanSecurityForm.get('margin').dirty) {
this.loanProductLoanSecurityForm.get('margin').setValue(profile.margin, { emitEvent: false });
}
if (!this.loanProductLoanSecurityForm.get('guarantorsRequired').dirty) {
this.loanProductLoanSecurityForm
.get('guarantorsRequired')
.setValue(profile.guarantorsRequired, { emitEvent: false });
}
([
'coApplicant',
'thirdPartySecurity'
] as const).forEach((name) => {
const control = this.loanProductLoanSecurityForm.get(name);
if (!control.dirty) {
control.setValue(profile[name], { emitEvent: false });
}
});
// Only the conditions that fit this kind of security apply; the rest are off and not shown.
const rules = CONDITIONS_BY_SUB_TYPE[subGroup] ?? [];
this.applicableConditions = rules;
CONDITION_CONTROLS.forEach((name) => {
const control = this.loanProductLoanSecurityForm.get(name);
if (!control.dirty) {
const rule = rules.find((entry) => entry.control === name);
control.setValue(rule ? rule.defaultOn : false, { emitEvent: false });
}
});
}
/** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
get ltv(): number {
const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
@@ -300,16 +119,8 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
}
get loanProductLoanSecurity() {
const value = this.loanProductLoanSecurityForm.getRawValue();
// A condition that is not shown (unsecured, or not part of this security) is never saved as on.
const shown = new Set(this.shownConditions.map((rule) => rule.control));
CONDITION_CONTROLS.forEach((name) => {
if (!shown.has(name)) {
value[name] = false;
}
});
return {
...value,
...this.loanProductLoanSecurityForm.getRawValue(),
ltv: this.ltv
};
}
@@ -7,14 +7,13 @@
-->
<form [formGroup]="loanProductPenalNpaForm">
<!-- 1. PENAL -->
<div class="adv-section pn-card pn-card--penal">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Penal Charges</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) {
@@ -22,11 +21,11 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) {
@@ -34,7 +33,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field>
@@ -46,14 +45,13 @@
</div>
</div>
<!-- 2. SMA -->
<div class="adv-section pn-card pn-card--sma">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-bell"></i>
<h3>SMA (Special Mention Account)</h3>
<i class="fas fa-triangle-exclamation"></i>
<h3>Asset Classification (SMA / NPA)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option>
@@ -63,38 +61,11 @@
</mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>SMA Counting Starts After (months)</mat-label>
<input matInput type="number" formControlName="smaStartAfterMonths" min="0" required />
<mat-hint>
{{ isAdvanceAccount ? 'Advances: from disbursement (default 9)' : 'Loans: first EMI due (default 1)' }}
</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="pn-stages">
<span class="pn-stage pn-stage--0">SMA-0 · 1–30 days</span>
<span class="pn-stage pn-stage--1">SMA-1 · 31–60 days</span>
<span class="pn-stage pn-stage--2">SMA-2 · 61–90 days</span>
</div>
</div>
<!-- 3. NPA -->
<div class="adv-section pn-card pn-card--npa">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>NPA (Non-Performing Asset)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) {
@@ -102,7 +73,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) {
@@ -110,45 +81,26 @@
}
</mat-select>
</mat-form-field>
@if (isRevolvingSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA Out-of-Order Basis (CC/OD)</mat-label>
<mat-select formControlName="npaOutOfOrderBasis">
@for (opt of npaOutOfOrderBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Stock Statement Validity (days)</mat-label>
<input matInput type="number" formControlName="stockStatementValidityDays" min="0" />
<mat-hint>Older statement + drawings 90 days = NPA</mat-hint>
</mat-form-field>
}
@if (isKccSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Crop Duration (KCC)</mat-label>
<mat-select formControlName="cropDuration">
@for (opt of cropDurationOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Grace on Arrears Ageing (days)</mat-label>
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-hint>Days before an amount counts as overdue</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="upgradeOnlyAllArrears">
Upgrade from NPA only when all arrears are cleared
</mat-checkbox>
@if (isDepositBacked) {
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
}
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
</div>
</div>
</div>
<!-- 4. ECL (SICR & LGD): hidden for now. The form controls stay in the .ts so the card can be restored. -->
</form>
@@ -6,16 +6,14 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Penal charges & asset-classification (SMA / NPA / SICR / LGD) step for a loan product (Figma step 6).
* Shared by both Advances A/C and Loans A/C flows — fields are sent in the create/update payload via
* `loanProductPenalNpa`, mapped onto the core product fields and the LoanProductAdvanceExt satellite params
* in create-loan-product.component.ts.
* Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-penal-npa-step',
@@ -26,40 +24,13 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox
]
})
export class LoanProductPenalNpaStepComponent implements OnInit {
export class LoanProductPenalNpaStepComponent {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
@Input() productTypeGroup: UntypedFormControl;
@Input() productTypeSubGroup: UntypedFormControl;
loanProductPenalNpaForm: UntypedFormGroup;
/** True for Advances A/C (cc/overdraft/kcc style limit products); drives which SICR/LGD fields show. */
isAdvanceAccount = false;
/** The ECL card is optional and starts collapsed. */
eclOpen = false;
/** One line for the collapsed ECL card. */
get eclSummary(): string {
const v = this.loanProductPenalNpaForm.getRawValue();
const days = this.isRevolvingSubType ? v.sicrRevolvingDaysThreshold : v.sicrDaysThreshold;
return `Stage 2 after ${days} days late · loss ${v.lgdSecuredPct}% with security, ${v.lgdUnsecuredPct}% without`;
}
/** The chosen sub type (e.g. 'cc', 'overdraft', 'kcc', 'gold') within the current product type group. */
subType: string | null = null;
/** FD / RD / DDS / LIC are lent against a deposit or policy; Gold, CC and the rest are pledged or hypothecated. */
get isDepositBacked(): boolean {
return [
'fd',
'rd',
'dds',
'lic'
].includes(this.subType ?? '');
}
readonly yesNoOptions = [
'YES',
'NO'
@@ -72,8 +43,7 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
readonly npaTriggerOptions = [
{ id: 'maturity', label: 'Dues overdue after maturity' },
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' },
{ id: 'crop_season', label: 'Overdue for crop season(s) (KCC)' }
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
];
readonly interestAfterNpaOptions = [
@@ -81,48 +51,10 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
{ id: 'suspense', label: 'Move to Interest Suspense GL' }
];
readonly sicrAssessmentBasisOptions = [
{ id: 'DPD', label: 'Days-Past-Due threshold only' },
{ id: 'RATING_DOWNGRADE', label: 'Internal rating downgrade only' },
{ id: 'PRICING_INCREASE', label: 'Risk-based repricing only' },
{ id: 'DPD_AND_RATING', label: 'DPD + rating downgrade (either triggers)' }
];
/** RBI 2026: CC/OD "out of order" basis (para on revolving facilities). */
readonly npaOutOfOrderBasisOptions = [
{ id: 'LIMIT_EXCEEDED', label: 'Outstanding continuously exceeds limit/drawing power' },
{ id: 'NO_CREDITS_90_DAYS', label: 'No credits for 90 days, or credits < interest debited' },
{ id: 'DP_NOT_RENEWED', label: 'Drawing power not reassessed/renewed within 180 days' }
];
readonly cropDurationOptions = [
{ id: 'KHARIF', label: 'Kharif (short duration)' },
{ id: 'RABI', label: 'Rabi (short duration)' },
{ id: 'LONG_DURATION', label: 'Long duration crop' }
];
readonly eligibleCollateralTypeOptions = [
{ id: 'NONE', label: 'None' },
{ id: 'GOLD', label: 'Gold / gold jewellery' },
{ id: 'COMMODITY', label: 'Commodity (warehouse receipt)' },
{ id: 'GOVERNMENT_SECURITIES', label: 'Government securities' },
{ id: 'BANK_DEPOSIT', label: 'Bank term deposit (lien-marked)' },
{ id: 'LIFE_INSURANCE', label: 'Life insurance policy (surrender value)' }
];
get delinquencyBucketOptions(): any[] {
return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
}
/** CC/OD are revolving facilities — RBI's "out of order" NPA basis and 60-day SICR window apply to them. */
get isRevolvingSubType(): boolean {
return this.subType === 'cc' || this.subType === 'overdraft';
}
get isKccSubType(): boolean {
return this.subType === 'kcc';
}
constructor() {
this.loanProductPenalNpaForm = this.formBuilder.group({
penalApplicable: ['YES'],
@@ -146,125 +78,13 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
npaTrigger: ['maturity'],
interestAfterNpa: ['undebited'],
graceOnArrearsAgeing: [0],
smaStartAfterMonths: [
1,
[
Validators.required,
Validators.min(0)
]
],
stockStatementValidityDays: [
90,
[Validators.min(0)]
],
inArrearsTolerance: [0],
upgradeOnlyAllArrears: [true],
keepStandardIfDepositCovers: [true],
// ---- SICR (Significant Increase in Credit Risk) — RBI 2026 ECL framework ----
sicrDaysThreshold: [
30,
[Validators.min(0)]
],
sicrRevolvingDaysThreshold: [
60,
[Validators.min(0)]
],
sicrRatingDowngradeNotches: [
2,
[Validators.min(0)]
],
sicrPricingIncreaseTriggerPct: [
1.5,
[
Validators.min(0),
Validators.max(100)
]
],
sicrAssessmentBasis: ['DPD'],
sicrRebuttalAllowed: [false],
npaOutOfOrderBasis: ['LIMIT_EXCEEDED'],
cropDuration: [''],
// ---- LGD (Loss Given Default) — regulatory backstop vs bank's own model ----
useOwnLgdModel: [false],
lgdSecuredPct: [
65,
[
Validators.min(0),
Validators.max(100)
]
],
lgdUnsecuredPct: [
70,
[
Validators.min(0),
Validators.max(100)
]
],
eligibleCollateralType: ['NONE']
keepStandardIfDepositCovers: [true]
});
}
ngOnInit() {
if (this.productTypeGroup) {
this.applyProductTypeRules(this.productTypeGroup.value === 'advances', this.productTypeSubGroup?.value ?? null);
this.productTypeGroup.valueChanges.subscribe((group: string) => {
this.applyProductTypeRules(group === 'advances', this.productTypeSubGroup?.value ?? null);
});
}
if (this.productTypeSubGroup) {
this.productTypeSubGroup.valueChanges.subscribe((subGroup: string) => {
this.applyProductTypeRules(this.productTypeGroup?.value === 'advances', subGroup);
});
}
}
/**
* CC/OD (revolving) default to the 60-day SICR window and show the "out of order" NPA basis; KCC shows
* the crop-duration field instead. Term/EMI loans use the plain 30-day DPD-based SICR window and hide
* both revolving-only fields. Only defaults the value the first time a sub type becomes relevant, so it
* doesn't clobber a value the user already edited.
*/
private applyProductTypeRules(isAdvance: boolean, subType: string | null): void {
this.isAdvanceAccount = isAdvance;
this.subType = isAdvance ? subType : null;
if (this.isRevolvingSubType && !this.loanProductPenalNpaForm.get('sicrAssessmentBasis')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ sicrAssessmentBasis: 'DPD_AND_RATING' });
}
// The security that backs the product decides which collateral counts for ECL.
const collateralBySubType: Record<string, string> = {
fd: 'BANK_DEPOSIT',
rd: 'BANK_DEPOSIT',
dds: 'BANK_DEPOSIT',
lic: 'LIFE_INSURANCE',
gold: 'GOLD',
warehouse: 'COMMODITY',
gold_emi: 'GOLD'
};
const collateral = subType ? collateralBySubType[subType] : undefined;
if (!this.loanProductPenalNpaForm.get('eligibleCollateralType')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ eligibleCollateralType: collateral ?? 'NONE' });
}
// SMA counting starts 9 months after disbursement for Advances, 1 month for Loans (EMI).
if (!this.loanProductPenalNpaForm.get('smaStartAfterMonths')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ smaStartAfterMonths: isAdvance ? 9 : 1 });
}
// NPA trigger follows the facility: CC/OD out-of-order, KCC crop season, everything else overdue days.
if (!this.loanProductPenalNpaForm.get('npaTrigger')?.dirty) {
const trigger = this.isRevolvingSubType ? 'out_of_order' : this.isKccSubType ? 'crop_season' : 'maturity';
this.loanProductPenalNpaForm.patchValue({ npaTrigger: trigger });
}
}
get loanProductPenalNpa() {
const value = this.loanProductPenalNpaForm.getRawValue();
// Advances have no instalments, so the 9-month start is carried as arrears-ageing grace (30-day months).
// Loans (EMI) need none: the first instalment falls due one month after disbursement already.
const months = Number(value.smaStartAfterMonths) || 0;
return { ...value, graceOnArrearsAgeing: this.isAdvanceAccount ? months * 30 : 0 };
return this.loanProductPenalNpaForm.getRawValue();
}
}
@@ -6,38 +6,12 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="rv">
@if (!readOnly) {
<p class="rv__intro">
Check the details below. Use <strong>Submit</strong> at the bottom to send the product for approval.
</p>
}
@for (section of sections; track section.title) {
<div class="rv__card">
<div class="rv__head"><i class="fas {{ section.icon }}"></i>{{ section.title }}</div>
<div class="rv__grid">
@for (item of section.rows; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</div>
}
@if (otherSettings.length > 0) {
<details class="rv__card rv__other">
<summary class="rv__head"><i class="fas fa-sliders"></i>Other settings ({{ otherSettings.length }})</summary>
<div class="rv__grid">
@for (item of otherSettings; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</details>
}
</div>
<mifosx-loan-product-summary
[action]="'preview'"
[loanProduct]="loanProduct"
[loanProductsTemplate]="loanProductsTemplate"
[paymentAllocations]="loanProduct.paymentAllocation"
[creditAllocations]="loanProduct.creditAllocation"
[supportedInterestRefundTypes]="loanProduct.supportedInterestRefundTypes"
>
</mifosx-loan-product-summary>
@@ -47,74 +47,3 @@ mat-divider {
.repayment-strategy {
color: $status-approved;
}
/* Review step: sections of label / value rows. */
.rv {
display: flex;
flex-direction: column;
gap: 14px;
&__intro {
margin: 0;
color: #475569;
font-size: 13px;
}
&__card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #f8fafc;
color: #1e293b;
font-size: 14px;
font-weight: 600;
cursor: default;
i {
color: #6366f1;
}
}
&__other > &__head {
cursor: pointer;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 24px;
padding: 6px 16px 10px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
&__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid #f1f5f9;
font-size: 13px;
}
&__label {
color: #64748b;
}
&__value {
color: #1e293b;
font-weight: 500;
text-align: right;
word-break: break-word;
}
}
@@ -6,452 +6,44 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { productCategoryLabel, productTypeLabel } from '../../loan-product-types';
import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
import { LoanProducts } from '../../loan-products';
import { LoanProductSummaryComponent } from '../../common/loan-product-summary/loan-product-summary.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface ReviewRow {
label: string;
value: string;
}
interface ReviewSection {
title: string;
icon: string;
rows: ReviewRow[];
}
/**
* Review step for the Advances and Loans wizards: a plain read-out of what is about to be submitted, in sections, with
* the GL accounts shown by name. (The standard Fineract summary expects option objects this wizard does not build.)
*/
@Component({
selector: 'mifosx-loan-product-preview-step',
templateUrl: './loan-product-preview-step.component.html',
styleUrls: ['./loan-product-preview-step.component.scss'],
imports: []
imports: [
...STANDALONE_SHARED_IMPORTS,
LoanProductSummaryComponent,
FaIconComponent
]
})
export class LoanProductPreviewStepComponent {
export class LoanProductPreviewStepComponent implements OnInit, OnChanges {
@Input() loanProductsTemplate: any;
@Input() accountingRuleData: any;
@Input() loanProduct: any;
@Input() isValid = true;
/** True on the product View page: the reader without the "submit for approval" wording. */
@Input() readOnly = false;
@Input() isValid: boolean = true;
@Output() submitEvent = new EventEmitter();
/** Keys shown in a section above; everything else non-empty goes to "Other settings". */
private readonly shown = new Set<string>();
private cachedFor: any = null;
private cachedSections: ReviewSection[] = [];
private cachedOther: ReviewRow[] = [];
isAdvancedPaymentAllocation = false;
/** Keys that are technical or already covered, never shown. */
private readonly hidden = new Set<string>([
'locale',
'dateFormat',
'charges',
'accountingRule',
'inMultiplesOf',
'repaymentEvery',
'repaymentFrequencyType',
'interestRateFrequencyType',
'amortizationType',
'interestType',
'interestCalculationPeriodType',
'transactionProcessingStrategyCode',
'daysInYearType',
'daysInMonthType',
'isInterestRecalculationEnabled',
'glAccount',
'interestRatePerPeriod'
]);
constructor() {}
get sections(): ReviewSection[] {
this.build();
return this.cachedSections;
ngOnInit() {
this.advancedPaymentAllocation();
}
get otherSettings(): ReviewRow[] {
this.build();
return this.cachedOther;
ngOnChanges(changes: SimpleChanges): void {
this.advancedPaymentAllocation();
}
/** Rebuilt only when the product object changes (it is stable until a form value changes). */
private build(): void {
const p = this.loanProduct;
if (!p || p === this.cachedFor) {
return;
}
this.cachedFor = p;
this.shown.clear();
const row = (label: string, key: string, format?: (value: any) => string): ReviewRow | null => {
this.shown.add(key);
const value = p[key];
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
return null;
}
return { label, value: format ? format(value) : this.format(value) };
};
const gl = (label: string, key: string) => row(label, key, (id) => this.glName(id));
const pct = (value: any) => `${value}%`;
const months = (value: any) => `${value} months`;
const rupees = (value: any) => `₹ ${Number(value).toLocaleString('en-IN')}`;
// An option id such as "fixed_day" read as words ("Fixed day").
const words = (value: any) => {
const text = String(value).replace(/_/g, ' ');
return text.charAt(0).toUpperCase() + text.slice(1);
};
const sections: ReviewSection[] = [
{
title: 'Product',
icon: 'fa-hand-holding-usd',
rows: [
{ label: 'Type', value: productCategoryLabel(p.productTypeGroup) },
{ label: 'Sub type', value: productTypeLabel(p.productTypeGroup, p.productTypeSubGroup) },
row('Name', 'name'),
row('Short name', 'shortName'),
gl('Product GL (asset)', 'glAccount'),
row('Start date', 'startDate'),
row('Close date', 'closeDate'),
row('Currency', 'currencyCode'),
row('Decimal places', 'digitsAfterDecimal')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Amount and tenure',
icon: 'fa-coins',
rows: [
row('Facility type', 'facilityType'),
row('Minimum amount / limit', 'minPrincipal', rupees),
row('Default amount / limit', 'principal', rupees),
row('Maximum amount / limit', 'maxPrincipal', rupees),
row('Minimum tenure', 'minNumberOfRepayments', months),
row('Default tenure', 'numberOfRepayments', months),
row('Maximum tenure', 'maxNumberOfRepayments', months),
row('Repayment type', 'repaymentType', (v) =>
v === 'bullet'
? 'Bullet: principal + interest at maturity'
: v === 'running'
? 'Running: any time, clear by limit expiry'
: v
),
row('Appropriation order', 'appropriationOrder', (v) =>
v === 'charges_penal_interest_principal'
? 'Charges, then penal, then interest, then principal'
: v === 'principal_interest_penal_charges'
? 'Principal, then interest, then penal, then charges'
: v
)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Interest',
icon: 'fa-percent',
rows: [
row('Rate register row', 'registerParticular'),
p.interestRatePerPeriod !== undefined
? { label: 'Rate (default)', value: pct(p.interestRatePerPeriod) }
: null,
row('Spread', 'spread', pct),
row('Calculation method', 'interestCalcMethod'),
row('Interest charged on', 'interestChargedOn'),
row('Posting frequency', 'interestPostingFrequency'),
row('Posting day', 'interestPostingDay')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Security and lien',
icon: 'fa-shield-halved',
rows: [
row('Security type', 'securityType'),
row(
'Eligible amount basis',
'eligibleAmountBasis',
(v) =>
(
({
gold_value: 'Net weight × purity × bank gold rate',
balance_accrued: 'Deposit balance + accrued interest',
balance: 'Deposit balance only',
surrender: 'Surrender value (LIC)',
commodity_value: 'Quantity × market price of the commodity'
}) as Record<string, string>
)[v] ?? v
),
row('Margin', 'margin', pct),
row('LTV', 'ltv', pct),
row('Lien required', 'lienRequired'),
row('Lien marking stage', 'lienMarkingStage'),
row('Eligible deposit products', 'eligibleDepositProducts'),
row('Lien certificate prefix', 'lienCertificatePrefix'),
row('Tenure cannot exceed deposit maturity', 'lienTenureNotExceedMaturity'),
row('Auto-release lien on closure', 'lienAutoReleaseOnClosure'),
row('Multiple deposits for one advance', 'lienAllowMultipleDeposits'),
row('Adjust from deposit at maturity', 'lienAdjustFromMaturity'),
row('Third-party deposit allowed', 'lienAllowThirdParty'),
row('Deposit renewal under lien', 'lienAllowRenewalUnderLien')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Gold details',
icon: 'fa-ring',
rows: [
row('Maximum LTV cap', 'goldMaxLtv', pct),
row('Minimum purity', 'goldMinPurityCarat', (v) => `${v} carat`),
row('Valuation rate source', 'goldRateSource', (v) =>
v === 'ibja_30d_avg' ? 'IBJA 30-day average' : 'Bank daily gold rate'
),
row('Allowed items', 'goldAllowedItems', (v) =>
v === 'ornaments_coins_50g' ? 'Ornaments + coins (up to 50 g)' : 'Ornaments only'
),
row('Max loan per gram', 'goldMaxPerGram', rupees),
row('Minimum net weight', 'goldMinNetWeight', (v) => `${v} g`),
row('Max net weight per customer', 'goldMaxNetWeightPerCustomer', (v) => `${v} g`),
row('Appraiser required', 'goldAppraiserRequired'),
row('Valuation certificate required', 'goldValuationCertificateRequired'),
row('Max valuation per appraiser', 'goldMaxValuationPerAppraiser', rupees),
row('Packet number prefix', 'goldPacketPrefix'),
row('Revalue gold when loan turns NPA', 'goldRevalueOnNpa'),
row('Charge customer for revaluation', 'goldRevaluationChargeApplicable'),
row('Insurance required', 'insuranceRequired'),
row('Partial release of ornaments', 'goldPartialReleaseAllowed'),
row('Third-party gold allowed', 'goldThirdPartyAllowed'),
row('Disbursement mode', 'goldDisbursementMode', (v) =>
v === 'account_credit' ? 'Account credit only' : 'Account credit; cash up to the limit'
),
row('Cash disbursement limit', 'goldCashDisbursementLimit', rupees),
row('LTV breach alert', 'goldLtvBreachAlert', pct),
row('Auction notice', 'goldAuctionNoticeDays', (v) => `${v} days`),
row('Auction reserve price', 'goldAuctionReservePct', (v) => `${v}% of market value`),
row('Customer may redeem before auction', 'goldRedeemBeforeAuction')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Warehouse receipt details',
icon: 'fa-warehouse',
rows: [
row('Maximum LTV cap', 'wrMaxLtv', pct),
row('Receipt type allowed', 'wrReceiptType', (v) =>
v === 'e_nwr' ? 'e-NWR (electronic)' : v === 'paper_nwr' ? 'Paper NWR' : 'Both e-NWR and paper NWR'
),
row('WDRA-registered warehouse', 'wrWdraRegistered'),
row('Quality / assay certificate', 'wrAssayCertificateRequired'),
row('Tenure within receipt validity', 'wrTenureWithinReceiptValidity'),
row('Pledge marked in repository first', 'wrPledgeMarkedBeforeDisbursement'),
row('Eligible commodity groups', 'wrCommodityGroups', (v) =>
String(v)
.split('|')
.map((id) => id.charAt(0).toUpperCase() + id.slice(1))
.join(', ')
),
row(
'Valuation price source',
'wrValuationPriceSource',
(v) =>
(
({
mandi_apmc: 'Mandi / APMC price',
msp: 'MSP (government support price)',
exchange_ncdex: 'Exchange price (NCDEX)',
bank_assessed: 'Bank-assessed price'
}) as Record<string, string>
)[v] ?? v
),
row('Max loan per quintal', 'wrMaxLoanPerQuintal', rupees),
row('Insurance required', 'insuranceRequired'),
row('Stock inspection every', 'wrStockInspectionMonths', months),
row('LTV breach alert', 'wrLtvBreachAlert', pct),
row('Partial release of stock', 'wrPartialReleaseAllowed'),
row('Disposal notice', 'wrDisposalNoticeDays', (v) => `${v} days`),
row('Disposal reserve price', 'wrDisposalReservePct', (v) => `${v}% of market value`),
row('Customer may redeem before disposal', 'wrRedeemBeforeDisposal')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Borrower eligibility',
icon: 'fa-user',
rows: [
row('Applicant must be a bank member', 'memberRequired'),
row('Guarantors must be bank members', 'guarantorMustBeMember'),
row('Minimum age', 'minAge', (v) => `${v} years`),
row('Maximum age at maturity', 'maxAgeAtMaturity', (v) => `${v} years`),
row('Minimum CIBIL score', 'minCibil'),
row('Minimum monthly income', 'minMonthlyIncome', rupees),
row('Income multiple', 'incomeMultiple', (v) => `${v}× net monthly salary`),
row('Maximum FOIR / DBR', 'maxFoir', pct),
row('Maximum loans per customer', 'maxLoansPerCustomer')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Security and guarantors (EMI loan)',
icon: 'fa-shield-halved',
rows: [
row('Security mandatory', 'securityMandatory', words),
row('Guarantors required', 'guarantorsRequired'),
row('Co-applicant', 'coApplicant', words),
row('Third-party security', 'thirdPartySecurity', words),
row('Legal opinion / title search', 'legalOpinionRequired'),
row('Insurance of the security', 'propertyInsuranceRequired'),
row('Charge registered before disbursement', 'registerCharge'),
row('Release security on closure', 'releaseSecurityOnClosure'),
row('Multiple securities allowed', 'allowMultipleSecurities')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Disbursement',
icon: 'fa-arrow-right',
rows: [
row('Disbursement type', 'disbursementType', words),
row('Disbursement mode', 'disbursementMode', words),
row('Interest on undisbursed amount', 'undisbursedInterest', words),
row('Pre-EMI interest', 'preEmiInterest')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Repayment and EMI',
icon: 'fa-calendar-days',
rows: [
row('EMI due day', 'emiDueDay', words),
row('If the due date is a holiday', 'holidayRule', words),
row('Moratorium', 'moratoriumMonths', months),
row('Interest during moratorium', 'moratoriumInterest', words),
row('Variable instalments', 'variableInstallments'),
row('Repayment mode', 'repaymentMode', words),
row('Linked account type', 'linkedAccountType', words),
row('Cheque (PDC) collection', 'pdcCollection', words),
row('Mandate required', 'mandateRequired'),
row('Advance EMI', 'advanceEmi', words)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Interest rate settings',
icon: 'fa-percent',
rows: [
row('Rate type', 'rateType', words),
row('CIBIL adjustment applied', 'applyCibilAdjustment'),
row('Rate decided at', 'rateResolvedAt', words),
row('Calculation period', 'calcPeriod', words),
row('Interest starts from', 'interestStartsFrom', words),
row('Compounding', 'compounding', words),
row('Benchmark', 'benchmark', words),
row('Reset frequency', 'resetFrequency', words),
row('On reset, change', 'resetChange', words)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Prepayment and foreclosure',
icon: 'fa-flag-checkered',
rows: [
row('Part-prepayment allowed', 'partPrepaymentAllowed'),
row('Minimum part-payment', 'minPartPayment', rupees),
row('Part-payment allowed on', 'allowedOn', words),
row('Part-payments per year', 'partPaymentsPerYear', words),
row('Part-payment mode', 'partPrepaymentMode', words),
row('Foreclosure: individual, floating rate', 'foreclosureFloatingIndividual', pct),
row('Foreclosure: individual, fixed rate', 'foreclosureFixedIndividual', pct),
row('Foreclosure: non-individual', 'foreclosureNonIndividual', pct),
row('Foreclosure: takeover', 'foreclosureTakeover', pct),
row('Foreclosure quote valid for', 'foreclosureQuoteValidDays', (v) => `${v} days`),
row('Close automatically at zero balance', 'autoCloseOnZeroBalance'),
row('NOC letter', 'nocLetter', words),
row('Restructuring', 'restructuring', words),
row('Top-up allowed', 'topUpAllowed'),
row('Balance transfer out allowed', 'balanceTransferOutAllowed')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Penal, SMA and NPA',
icon: 'fa-triangle-exclamation',
rows: [
row('Penal charges', 'penalApplicable'),
row('Penal rate', 'penalRate', pct),
row('Penal applied on', 'penalAppliedOn'),
row('Penal grace days', 'penalGraceDays'),
row('SMA counting starts after', 'smaStartAfterMonths', months),
row('NPA after', 'overdueDaysForNPA', (v) => `${v} days overdue`),
row('NPA trigger', 'npaTrigger'),
row('Stock statement validity', 'stockStatementValidityDays', (v) => `${v} days`),
row('Interest after NPA', 'interestAfterNpa'),
row('Upgrade only when all arrears are cleared', 'npaUpgradeOnlyAllArrears'),
row('Keep standard while deposit covers dues', 'npaKeepStandardIfDepositCovers')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Renewal and closure',
icon: 'fa-rotate',
rows: [
row('Renewal allowed', 'renewalAllowed'),
row('Renewal period', 'renewalPeriodMonths', months),
row('Renewal grace', 'renewalGraceDays', (v) => `${v} days`),
row('Pre-closure', 'preclosureAllowed'),
row('Lock-in', 'lockInMonths', months),
row('Interest on pre-closure', 'interestOnPreclosure')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Charges',
icon: 'fa-receipt',
rows: [row('Attached charges', 'chargesSummary')].filter((r): r is ReviewRow => !!r)
},
{
title: 'Accounting (GL accounts)',
icon: 'fa-book',
rows: [
gl('Loan portfolio', 'loanPortfolioAccountId'),
gl('Fund source', 'fundSourceAccountId'),
gl('Interest income', 'interestOnLoanAccountId'),
gl('Fee income', 'incomeFromFeeAccountId'),
gl('Penal income', 'incomeFromPenaltyAccountId'),
gl('Write-off', 'writeOffAccountId'),
gl('Interest receivable', 'receivableInterestAccountId'),
gl('Overpayment', 'overpaymentLiabilityAccountId'),
gl('Interest suspense', 'undebitedInterestGlId')
].filter((r): r is ReviewRow => !!r)
}
].filter((section) => section.rows.length > 0);
// The group and sub type are shown through the label row above.
this.shown.add('productTypeGroup');
this.shown.add('productTypeSubGroup');
this.cachedSections = sections;
// On the View page the product also carries every raw core field; only the extension record is "other settings".
const candidates = this.readOnly ? Object.keys(p.advanceExtensions ?? {}) : Object.keys(p);
this.cachedOther = candidates
.filter((key) => key !== 'advanceExtensions' && !this.shown.has(key) && !this.hidden.has(key))
.filter(
(key) => p[key] !== undefined && p[key] !== null && p[key] !== '' && !(Array.isArray(p[key]) && !p[key].length)
)
.map((key) => ({ label: this.humanize(key), value: this.format(p[key]) }));
}
private glName(id: any): string {
const options = this.loanProductsTemplate?.accountingMappingOptions ?? {};
const all: any[] = [
...(options.assetAccountOptions ?? []),
...(options.incomeAccountOptions ?? []),
...(options.expenseAccountOptions ?? []),
...(options.liabilityAccountOptions ?? []),
...(options.equityAccountOptions ?? [])
];
const account = all.find((entry) => String(entry.id) === String(id));
return account ? `${account.glCode} ${account.name}` : String(id);
}
private humanize(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
private format(value: any): string {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
return value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', ');
}
return typeof value === 'object' ? JSON.stringify(value) : String(value);
advancedPaymentAllocation() {
this.isAdvancedPaymentAllocation = LoanProducts.isAdvancedPaymentAllocationStrategy(
this.loanProduct.transactionProcessingStrategyCode
);
}
}
@@ -27,29 +27,15 @@
<mat-hint>From Sub Type — not chosen again</mat-hint>
</mat-form-field>
@if (isGold || isWarehouse) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<input matInput [value]="isGold ? goldBasisLabel : warehouseBasisLabel" disabled />
<mat-hint>
@if (isGold) {
Fixed for Gold: valued on weight, purity and the gold rate
} @else {
Fixed for Warehouse Receipt: valued on quantity and the commodity's market price
}
</mat-hint>
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Margin (%)</mat-label>
@@ -67,424 +53,9 @@
<input matInput [value]="ltv" disabled />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
@if (isGold) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Maximum LTV Cap (%)</mat-label>
<input matInput type="number" formControlName="goldMaxLtv" min="1" max="100" step="0.01" required />
<mat-hint>No account may be sanctioned above this LTV (RBI ceiling 75%)</mat-hint>
@if (loanProductSecurityLienForm.get('goldMaxLtv').hasError('required')) {
<mat-error>Maximum LTV Cap is <strong>required</strong></mat-error>
} @else {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
}
@if (isWarehouse) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Maximum LTV Cap (%)</mat-label>
<input matInput type="number" formControlName="wrMaxLtv" min="1" max="100" step="0.01" required />
<mat-hint>No account may be sanctioned above this LTV</mat-hint>
@if (loanProductSecurityLienForm.get('wrMaxLtv').hasError('required')) {
<mat-error>Maximum LTV Cap is <strong>required</strong></mat-error>
} @else {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
}
</div>
@if ((isGold || isWarehouse) && loanProductSecurityLienForm.hasError('ltvAboveCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV (100 − Margin) is above the Maximum LTV Cap. Raise the margin or the cap.
</div>
}
</div>
@if (isGold) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-ring"></i>
<h3>Gold Details</h3>
<span class="lsl-section__hint">Pledged ornaments — no lien marking</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Valuation basis</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Minimum Purity (carat)</mat-label>
<mat-select formControlName="goldMinPurityCarat" required>
@for (carat of goldPurityOptions; track carat) {
<mat-option [value]="carat">{{ carat }} carat</mat-option>
}
</mat-select>
<mat-hint>Ornaments below this purity are not accepted</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Rate Source</mat-label>
<mat-select formControlName="goldRateSource" required>
@for (opt of goldRateSourceOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Allowed Items</mat-label>
<mat-select formControlName="goldAllowedItems" required>
@for (opt of goldAllowedItemsOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Loan per Gram (₹)</mat-label>
<input matInput type="number" formControlName="goldMaxPerGram" min="1" />
<mat-hint>Optional bank cap on the gold rate</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Min Net Weight (g)</mat-label>
<input matInput type="number" formControlName="goldMinNetWeight" min="0" step="0.01" />
<mat-hint>Optional</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Net Weight per Customer (g)</mat-label>
<input matInput type="number" formControlName="goldMaxNetWeightPerCustomer" min="0" step="0.01" />
<mat-hint>Optional</mat-hint>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('netWeightRange')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
Minimum net weight cannot exceed the maximum per customer.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Valuation by appraiser</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Appraiser Required</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Certificate Required</mat-label>
<mat-select formControlName="goldValuationCertificateRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Valuation per Appraiser (₹)</mat-label>
<input matInput type="number" formControlName="goldMaxValuationPerAppraiser" min="1" />
<mat-hint>Optional default limit for one appraiser</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Safe custody</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Packet No. Prefix</mat-label>
<input matInput formControlName="goldPacketPrefix" maxlength="20" />
<mat-hint>The packet number itself is set per account</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Insurance Required</mat-label>
<mat-select formControlName="insuranceRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Revaluation</p>
<div class="lsl-checks">
<mat-checkbox formControlName="goldRevalueOnNpa">
Revalue the gold when the loan turns NPA (short-term loans are not revalued on a schedule)
</mat-checkbox>
<mat-checkbox formControlName="goldRevaluationChargeApplicable">
Charge the customer for revaluation (set the fee on the Charges step)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Loan servicing rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="goldPartialReleaseAllowed">
Partial release of ornaments allowed on part payment (LTV must stay within the cap)
</mat-checkbox>
<mat-checkbox formControlName="goldThirdPartyAllowed">
Third-party gold allowed (relative's gold, consent letter required)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Disbursement and monitoring</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disbursement Mode</mat-label>
<mat-select formControlName="goldDisbursementMode" required>
@for (opt of goldDisbursementModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductSecurityLienForm.get('goldDisbursementMode').value === 'account_credit_cash_limit') {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Cash Disbursement Limit (₹)</mat-label>
<input
matInput
type="number"
formControlName="goldCashDisbursementLimit"
min="0"
[max]="goldCashLimitMax"
/>
<mat-hint>Cash above this is not allowed (Income Tax Sec 269SS)</mat-hint>
<mat-error>Must be between 0 and {{ goldCashLimitMax }}</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV Breach Alert (%)</mat-label>
<input
matInput
type="number"
formControlName="goldLtvBreachAlert"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Flag the account when the live LTV (after a gold rate fall) crosses this level</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('alertBelowCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV Breach Alert cannot be below the Maximum LTV Cap.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Auction and recovery</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Auction Notice Period (days)</mat-label>
<input matInput type="number" formControlName="goldAuctionNoticeDays" min="0" required />
<mat-hint>Notice given before an auction after NPA</mat-hint>
<mat-error>Auction Notice Period is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Auction Reserve Price (% of market value)</mat-label>
<input
matInput
type="number"
formControlName="goldAuctionReservePct"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Gold is not sold below this share of the market value</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox [checked]="true" disabled>
Customer may redeem (clear dues and take the gold) any time before the auction. Always on
</mat-checkbox>
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Packet number, item list, weights, carat, appraiser and valuation date are captured per account at
<strong>Loan Account Creation</strong>, not on the product.
</div>
}
@if (isWarehouse) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-warehouse"></i>
<h3>Warehouse Receipt Details</h3>
<span class="lsl-section__hint">Pledged commodity — no lien marking</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Receipt</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Receipt Type Allowed</mat-label>
<mat-select formControlName="wrReceiptType" required>
@for (opt of wrReceiptTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-hint>e-NWR is pledge-marked online in the repository</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Warehouse WDRA-registered</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Quality / Assay Certificate</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox formControlName="wrTenureWithinReceiptValidity">
Tenure cannot exceed the receipt's validity date
</mat-checkbox>
<mat-checkbox formControlName="wrPledgeMarkedBeforeDisbursement">
Receipt must be pledge-marked in the repository before disbursement
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Commodity and valuation</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field lsl-field--full">
<mat-label>Eligible Commodity Groups</mat-label>
<mat-select formControlName="wrCommodityGroups" multiple required>
@for (opt of wrCommodityGroupOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-hint>Only receipts for these commodities can be pledged</mat-hint>
<mat-error>Pick at least one commodity group</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Price Source</mat-label>
<mat-select formControlName="wrValuationPriceSource" required>
@for (opt of wrValuationSourceOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Loan per Quintal (₹)</mat-label>
<input matInput type="number" formControlName="wrMaxLoanPerQuintal" min="1" />
<mat-hint>Optional bank cap on the price</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Insurance and inspection</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Insurance Required</mat-label>
<mat-select formControlName="insuranceRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Stock Inspection Every (months)</mat-label>
<input matInput type="number" formControlName="wrStockInspectionMonths" min="1" required />
<mat-hint>Physical check that the stock is in the warehouse</mat-hint>
<mat-error>Required, at least 1</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV Breach Alert (%)</mat-label>
<input matInput type="number" formControlName="wrLtvBreachAlert" min="1" max="100" step="0.01" required />
<mat-hint>Flag the account when a price fall pushes the live LTV above this</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('alertBelowCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV Breach Alert cannot be below the Maximum LTV Cap.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Loan servicing rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="wrPartialReleaseAllowed">
Partial release of stock allowed on part payment (LTV must stay within the cap)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Disposal and recovery</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disposal Notice Period (days)</mat-label>
<input matInput type="number" formControlName="wrDisposalNoticeDays" min="0" required />
<mat-hint>Notice given before the stock is sold after NPA</mat-hint>
<mat-error>Disposal Notice Period is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disposal Reserve Price (% of market value)</mat-label>
<input
matInput
type="number"
formControlName="wrDisposalReservePct"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Stock is not sold below this share of the market value</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox [checked]="true" disabled>
Customer may redeem (clear dues and take the stock) any time before the disposal. Always on
</mat-checkbox>
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Commodity, quantity, grade, warehouse, receipt number and validity, repository pledge reference and the market
price on the sanction date are captured per account at <strong>Loan Account Creation</strong>, not on the
product.
</div>
}
@if (isLienSubType) {
<div class="lsl-section">
<div class="lsl-section__header">
@@ -115,20 +115,3 @@ $gray-900: #202124;
color: $accent;
}
}
.lsl-error {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 24px 16px;
padding: 10px 14px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
font-size: 13px;
color: #b91c1c;
}
.lsl-checks--spaced {
margin-top: 14px;
}
@@ -67,65 +67,9 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
fd: 10,
rd: 10,
dds: 10,
lic: 40,
gold: 25,
warehouse: 25
lic: 40
};
/** RBI ceiling a gold loan may be sanctioned at; the product's own LTV cap defaults to it. */
private readonly goldDefaultLtvCap = 75;
readonly goldPurityOptions = [
18,
20,
22,
24
];
readonly goldRateSourceOptions = [
{ id: 'bank_daily', label: 'Bank daily gold rate' },
{ id: 'ibja_30d_avg', label: 'IBJA 30-day average' }
];
readonly goldAllowedItemsOptions = [
{ id: 'ornaments_only', label: 'Ornaments only' },
{ id: 'ornaments_coins_50g', label: 'Ornaments + coins (up to 50 g)' }
];
readonly goldBasisLabel = 'Net weight × purity × bank gold rate';
// ---- Loan against Warehouse Receipt ----
readonly warehouseBasisLabel = 'Quantity × market price of the commodity';
readonly wrReceiptTypeOptions = [
{ id: 'e_nwr', label: 'e-NWR (electronic)' },
{ id: 'paper_nwr', label: 'Paper NWR' },
{ id: 'both', label: 'Both' }
];
readonly wrCommodityGroupOptions = [
{ id: 'cereals', label: 'Cereals' },
{ id: 'pulses', label: 'Pulses' },
{ id: 'oilseeds', label: 'Oilseeds' },
{ id: 'spices', label: 'Spices' },
{ id: 'others', label: 'Others' }
];
readonly wrValuationSourceOptions = [
{ id: 'mandi_apmc', label: 'Mandi / APMC price' },
{ id: 'msp', label: 'MSP (government support price)' },
{ id: 'exchange_ncdex', label: 'Exchange price (NCDEX)' },
{ id: 'bank_assessed', label: 'Bank-assessed price' }
];
readonly goldDisbursementModeOptions = [
{ id: 'account_credit', label: 'Account credit only' },
{ id: 'account_credit_cash_limit', label: 'Account credit; cash up to the limit' }
];
/** The most that may be paid out in cash against one gold loan (Income Tax Sec 269SS). */
readonly goldCashLimitMax = 20000;
readonly eligibleAmountBasisOptions = [
{ id: 'balance_accrued', label: 'Deposit balance + accrued interest' },
{ id: 'balance', label: 'Deposit balance only' },
@@ -141,18 +85,9 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
'Fixed Deposit – Regular',
'Fixed Deposit – Senior Citizen',
'Recurring Deposit – Regular',
'Daily Deposit Scheme',
'LIC Policy'
'Daily Deposit Scheme'
];
/** The deposit or policy a sub type is lent against; this is the default "Eligible Deposit Products". */
private readonly defaultDepositBySubGroup: Record<string, string> = {
fd: 'Fixed Deposit – Regular',
rd: 'Recurring Deposit – Regular',
dds: 'Daily Deposit Scheme',
lic: 'LIC Policy'
};
readonly yesNoOptions = [
'YES',
'NO'
@@ -176,23 +111,6 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false });
}
// Gold is valued on weight, purity and the gold rate; the deposit bases apply to every other type.
const basis = this.loanProductSecurityLienForm.get('eligibleAmountBasis');
if (subGroup === 'gold') {
basis.setValue('gold_value', { emitEvent: false });
} else if (subGroup === 'warehouse') {
basis.setValue('commodity_value', { emitEvent: false });
} else if (basis.value === 'gold_value' || basis.value === 'commodity_value') {
basis.setValue('balance_accrued', { emitEvent: false });
}
this.loanProductSecurityLienForm.updateValueAndValidity({ emitEvent: false });
// The deposit list follows the sub type until the officer has chosen their own.
// A sub type with no deposit of its own (gold, CC ...) keeps the plain FD default; the list is hidden for it.
const deposit = this.defaultDepositBySubGroup[subGroup] ?? 'Fixed Deposit – Regular';
const deposits = this.loanProductSecurityLienForm.get('eligibleDepositProducts');
if (deposit && deposits && !deposits.dirty) {
deposits.setValue([deposit], { emitEvent: false });
}
}
/** This whole step is only relevant to an Advances A/C product. */
@@ -200,16 +118,6 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
return this.productTypeGroup?.value === 'advances';
}
/** Loan against Gold: pledged ornaments, valued by an appraiser; no lien or deposit list. */
get isGold(): boolean {
return this.productTypeSubGroup?.value === 'gold';
}
/** Loan against Warehouse Receipt: a pledged commodity, valued on quantity and market price; no lien or deposit list. */
get isWarehouse(): boolean {
return this.productTypeSubGroup?.value === 'warehouse';
}
/** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */
get isLienSubType(): boolean {
return this.lienSubGroups.includes(this.productTypeSubGroup?.value);
@@ -225,216 +133,41 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
}
createForm() {
this.loanProductSecurityLienForm = this.formBuilder.group(
{
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES'],
// ---- Loan against Gold (hidden and not sent for any other type) ----
goldMaxLtv: [
this.goldDefaultLtvCap,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
goldMinPurityCarat: [
18,
Validators.required
],
goldRateSource: [
'bank_daily',
Validators.required
],
goldMaxPerGram: [
'',
Validators.min(1)
],
goldMinNetWeight: [
'',
Validators.min(0)
],
goldMaxNetWeightPerCustomer: [
'',
Validators.min(0)
],
goldAllowedItems: [
'ornaments_only',
Validators.required
],
goldValuationCertificateRequired: ['YES'],
goldMaxValuationPerAppraiser: [
'',
Validators.min(1)
],
goldPacketPrefix: [
'GL/',
Validators.maxLength(20)
],
goldRevalueOnNpa: [true],
goldRevaluationChargeApplicable: [true],
insuranceRequired: ['YES'],
goldAuctionNoticeDays: [
15,
[
Validators.required,
Validators.min(0)
]
],
goldPartialReleaseAllowed: [true],
goldThirdPartyAllowed: [false],
goldDisbursementMode: [
'account_credit_cash_limit',
Validators.required
],
goldCashDisbursementLimit: [
this.goldCashLimitMax,
[
Validators.min(0),
Validators.max(this.goldCashLimitMax)
]
],
goldLtvBreachAlert: [
80,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
goldAuctionReservePct: [
90,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
// ---- Loan against Warehouse Receipt (hidden and not sent for any other type) ----
wrMaxLtv: [
75,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
wrReceiptType: [
'e_nwr',
Validators.required
],
wrTenureWithinReceiptValidity: [true],
wrPledgeMarkedBeforeDisbursement: [true],
wrCommodityGroups: [
[
'cereals',
'pulses',
'oilseeds'
],
Validators.required
],
wrValuationPriceSource: [
'mandi_apmc',
Validators.required
],
wrMaxLoanPerQuintal: [
'',
Validators.min(1)
],
wrStockInspectionMonths: [
3,
[
Validators.required,
Validators.min(1)
]
],
wrLtvBreachAlert: [
80,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
wrPartialReleaseAllowed: [true],
wrDisposalNoticeDays: [
15,
[
Validators.required,
Validators.min(0)
]
],
wrDisposalReservePct: [
90,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
this.loanProductSecurityLienForm = this.formBuilder.group({
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
},
{ validators: (group: AbstractControl) => this.pledgeRules(group) }
);
}
/**
* Pledged-security rules (Gold, Warehouse): the LTV the margin gives must fit under the product's LTV cap, the breach
* alert must not sit below the cap, and a gold net-weight range must run low to high.
*/
private pledgeRules(group: AbstractControl): Record<string, boolean> | null {
const gold = this.isGold;
if (!gold && !this.isWarehouse) {
return null;
}
const errors: Record<string, boolean> = {};
const margin = Number(group.get('margin')?.value);
const cap = Number(group.get(gold ? 'goldMaxLtv' : 'wrMaxLtv')?.value);
if (!isNaN(margin) && !isNaN(cap) && 100 - margin > cap) {
errors['ltvAboveCap'] = true;
}
const alert = Number(group.get(gold ? 'goldLtvBreachAlert' : 'wrLtvBreachAlert')?.value);
if (!isNaN(alert) && !isNaN(cap) && alert < cap) {
errors['alertBelowCap'] = true;
}
if (gold) {
const min = group.get('goldMinNetWeight')?.value;
const max = group.get('goldMaxNetWeightPerCustomer')?.value;
if (min !== '' && max !== '' && min !== null && max !== null && Number(min) > Number(max)) {
errors['netWeightRange'] = true;
}
}
return Object.keys(errors).length ? errors : null;
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES']
});
}
/** Flat values for the payload. Not wired into submit yet — see the class comment. */
@@ -457,45 +190,7 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
},
lienCertificateRequired: value.lienCertificateRequired,
lienCertificatePrefix: value.lienCertificatePrefix,
printOnDisbursement: value.printOnDisbursement,
gold: {
maxLtv: value.goldMaxLtv,
minPurityCarat: value.goldMinPurityCarat,
rateSource: value.goldRateSource,
maxPerGram: value.goldMaxPerGram,
minNetWeight: value.goldMinNetWeight,
maxNetWeightPerCustomer: value.goldMaxNetWeightPerCustomer,
allowedItems: value.goldAllowedItems,
valuationCertificateRequired: value.goldValuationCertificateRequired,
maxValuationPerAppraiser: value.goldMaxValuationPerAppraiser,
packetPrefix: value.goldPacketPrefix,
revalueOnNpa: value.goldRevalueOnNpa,
revaluationChargeApplicable: value.goldRevaluationChargeApplicable,
insuranceRequired: value.insuranceRequired,
auctionNoticeDays: value.goldAuctionNoticeDays,
partialReleaseAllowed: value.goldPartialReleaseAllowed,
thirdPartyAllowed: value.goldThirdPartyAllowed,
disbursementMode: value.goldDisbursementMode,
cashDisbursementLimit: value.goldCashDisbursementLimit,
ltvBreachAlert: value.goldLtvBreachAlert,
auctionReservePct: value.goldAuctionReservePct,
redeemBeforeAuction: true
},
warehouse: {
maxLtv: value.wrMaxLtv,
receiptType: value.wrReceiptType,
tenureWithinReceiptValidity: value.wrTenureWithinReceiptValidity,
pledgeMarkedBeforeDisbursement: value.wrPledgeMarkedBeforeDisbursement,
commodityGroups: value.wrCommodityGroups,
valuationPriceSource: value.wrValuationPriceSource,
maxLoanPerQuintal: value.wrMaxLoanPerQuintal,
stockInspectionMonths: value.wrStockInspectionMonths,
insuranceRequired: value.insuranceRequired,
ltvBreachAlert: value.wrLtvBreachAlert,
partialReleaseAllowed: value.wrPartialReleaseAllowed,
disposalNoticeDays: value.wrDisposalNoticeDays,
disposalReservePct: value.wrDisposalReservePct
}
printOnDisbursement: value.printOnDisbursement
};
}
}
@@ -45,11 +45,6 @@ export interface RegisterRateSelection {
const PARTICULAR_BY_SUB_TYPE: Record<string, string> = {
fd: 'against fdr',
rd: 'against rd',
dds: 'against dds',
lic: 'against lic',
warehouse: 'warehouse',
bills: 'bills purchased',
education: 'education loan',
gold: 'gold loan',
cc: 'cash credit',
overdraft: 'overdraft',
@@ -81,33 +81,9 @@
placeholder="Search by name or short name…"
/>
</div>
<div class="pl-filters">
<label class="pl-filter">
<span>Category</span>
<select [(ngModel)]="categoryFilter" (ngModelChange)="onCategoryChange()">
<option value="">All categories</option>
@for (cat of categories; track cat.id) {
<option [value]="cat.id">{{ cat.label }}</option>
}
</select>
</label>
<label class="pl-filter">
<span>Product type</span>
<select [(ngModel)]="typeFilter" (ngModelChange)="refreshFilter()">
<option value="">All types</option>
@for (type of typeOptions; track type.id) {
<option [value]="type.id">{{ type.label }}</option>
}
</select>
</label>
<label class="pl-filter pl-filter--check">
<input type="checkbox" [(ngModel)]="activeOnly" (ngModelChange)="refreshFilter()" />
<span>Active only</span>
</label>
</div>
</div>
@if (totalCount === 0 && pendingRows.length === 0) {
@if (totalCount === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<h3>No loan products yet</h3>
@@ -147,16 +123,6 @@
</td>
</ng-container>
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef>Category</th>
<td mat-cell *matCellDef="let p">{{ categoryOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="productType">
<th mat-header-cell *matHeaderCellDef>Product Type</th>
<td mat-cell *matCellDef="let p">{{ typeOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="closeDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Expiry Date' | translate }}</th>
<td mat-cell *matCellDef="let p">
@@ -171,100 +137,48 @@
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
@if (p.pending) {
<span
class="pl-status-pill pl-status-pill--pending"
matTooltip="Requested by {{ p.madeBy }}; visible as a product once a checker approves"
>
<fa-icon icon="hourglass-half"></fa-icon> Pending approval
</span>
} @else {
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
@if (pendingUpdates.get(p.id); as update) {
<button
type="button"
class="pl-status-pill pl-status-pill--pending"
style="margin-left: 6px; cursor: pointer"
matTooltip="An edit by {{ update.maker }} is waiting for a checker. Click to see what changes."
(click)="$event.stopPropagation(); viewUpdate(p)"
>
<fa-icon icon="hourglass-half"></fa-icon> Update pending: view changes
</button>
}
}
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num">
@if (p.pending) {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
(click)="$event.stopPropagation(); viewPending(p)"
matTooltip="View the submitted details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
disabled
(click)="$event.stopPropagation()"
matTooltip="Locked until the request is approved or rejected"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
} @else {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[disabled]="pendingUpdates.has(p.id)"
(click)="$event.stopPropagation()"
[matTooltip]="
pendingUpdates.has(p.id)
? 'Locked until the pending edit is approved or rejected'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
}
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="row.pending ? null : [row.id]"
(click)="row.pending && viewPending(row)"
[class.pl-row--pending]="row.pending"
class="pl-row"
></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
@@ -61,18 +61,3 @@
background: #ede9fe;
color: #5b21b6;
}
.pl-status-pill--pending {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.pl-row--pending {
background: #fffbeb;
}
.pl-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@@ -8,7 +8,6 @@
/** Angular Imports */
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatDialog } from '@angular/material/dialog';
@@ -28,8 +27,8 @@ import {
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** rxjs Imports */
import { switchMap, catchError, finalize, map, tap } from 'rxjs/operators';
import { EMPTY, forkJoin, of } from 'rxjs';
import { switchMap, catchError, finalize } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
/* Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
@@ -43,9 +42,6 @@ import { MatTooltip } from '@angular/material/tooltip';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductEditDiffDialogComponent } from 'app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component';
import { withExtensions } from './loan-product-original.util';
import { PendingProductViewDialogComponent } from './pending-product-view-dialog/pending-product-view-dialog.component';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types';
@Component({
@@ -54,7 +50,6 @@ import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTy
styleUrls: ['./loan-products.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTable,
MatSort,
@@ -85,10 +80,6 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
private errorHandler = inject(ErrorHandlerService);
loanProductsData: any[] = [];
/** Create requests a checker has not approved yet; shown in the table with a Pending Approval status. */
pendingRows: any[] = [];
/** Saved products that have an edit waiting for a checker, by product id (value: the pending request). */
pendingUpdates = new Map<number, any>();
displayedColumns: string[] = [
'name',
'shortName',
@@ -139,8 +130,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
return this.totalCount - this.activeCount;
}
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
/* Reference of create loan product button */
@ViewChild('buttonCreateLoanProduct') buttonCreateLoanProduct: ElementRef<any>;
@@ -169,75 +160,7 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
this.dataSource.sort = this.sort;
this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product);
this.filteredCount = this.totalCount;
this.dataSource.connect().subscribe(() => (this.filteredCount = this.dataSource.filteredData.length));
this.loadPendingRequests();
}
/** Pending loan product requests become table rows, so a submitted product is visible straight away. */
private loadPendingRequests(): void {
this.productsService
.getPendingMakerCheckerByEntity('LOANPRODUCT')
.pipe(
catchError(() => of([])),
tap((entries: any[]) => {
this.pendingUpdates = new Map(
(entries ?? [])
.filter((entry) => (entry.actionName ?? '').toUpperCase() === 'UPDATE' && entry.resourceId != null)
.map(
(entry) => [
Number(entry.resourceId),
entry
] as [number, any]
)
);
}),
switchMap((entries: any[]) =>
(entries ?? []).filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE').length
? forkJoin(
entries
.filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE')
.map((entry) =>
this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe(
catchError(() => of(null)),
map((audit) => this.pendingRow(entry, audit))
)
)
)
: of([])
)
)
.subscribe((rows: any[]) => {
this.pendingRows = rows;
this.dataSource.data = [
...rows,
...this.loanProductsData
];
});
}
private pendingRow(entry: any, audit: any): any {
let payload: any = {};
try {
payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {};
} catch {
payload = {};
}
return {
id: null,
pending: true,
pendingRequestId: entry.id,
name: payload.name ?? 'New loan product',
shortName: payload.shortName ?? '—',
currency: payload.currencyCode ? { code: payload.currencyCode } : null,
closeDate: null,
status: 'pending',
madeBy: entry.maker,
payload,
advanceExtensions: {
productTypeGroup: payload.productTypeGroup,
productTypeSubGroup: payload.productTypeSubGroup
}
};
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
}
applyFilter(filterValue: string) {
@@ -287,8 +210,6 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
* To show popover.
*/
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
if (this.configurationWizardService.showLoanProductsPage) {
setTimeout(() => {
this.showPopover(
@@ -346,34 +267,6 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
/**
* Opens the import loan product dialog.
*/
/** What a pending edit would change: the saved product and the submitted edit, side by side. */
viewUpdate(row: any): void {
const request = this.pendingUpdates.get(Number(row.id));
if (!request) {
return;
}
forkJoin({
audit: this.productsService.getLoanRateRegisterAuditDetail(request.id),
product: this.productsService.getLoanProduct(row.id, true)
}).subscribe(({ audit, product }: any) => {
let proposed: any = {};
try {
proposed = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {};
} catch {
proposed = {};
}
this.dialog.open(ProductEditDiffDialogComponent, {
width: '820px',
data: { productName: row.name, original: withExtensions(product), proposed }
});
});
}
/** A pending product has no page yet, so its submitted values open in a read-only dialog. */
viewPending(row: any): void {
this.dialog.open(PendingProductViewDialogComponent, { width: '40rem', data: row });
}
openImportDialog(): void {
const importDialogRef = this.dialog.open(ImportLoanProductDialogComponent, {
width: '50rem'
@@ -425,10 +318,7 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
.subscribe({
next: (data: any) => {
this.loanProductsData = data;
this.dataSource.data = [
...this.pendingRows,
...this.loanProductsData
];
this.dataSource.data = this.loanProductsData;
this.errorHandler.showSuccess('Loan product imported successfully!');
},
error: () => {
@@ -1,39 +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/.
-->
<h1 mat-dialog-title>{{ data.name }}</h1>
<mat-dialog-content>
<div class="ppv__banner">
<i class="fas fa-hourglass-half"></i>
<span>
Awaiting checker approval. Requested by <strong>{{ data.madeBy }}</strong
>. This product is not active yet and cannot be edited until the request is approved or rejected.
</span>
</div>
<div class="ppv__chips">
<span class="ppv__chip">{{ category }}</span>
<span class="ppv__chip">{{ type }}</span>
<span class="ppv__chip">Short name: {{ data.shortName }}</span>
</div>
<table class="ppv__table">
<tbody>
@for (field of fields; track field.label) {
<tr>
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
}
</tbody>
</table>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-dialog-close class="ppv__close">Close</button>
</mat-dialog-actions>
@@ -1,74 +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/.
*/
.ppv__banner {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px;
margin-bottom: 12px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
i {
margin-top: 2px;
color: #d97706;
}
}
.ppv__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.ppv__chip {
padding: 3px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 12px;
font-weight: 600;
}
.ppv__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 6px 8px;
border-bottom: 1px solid #f1f5f9;
text-align: left;
vertical-align: top;
}
th {
width: 42%;
font-weight: 500;
color: #64748b;
}
td {
color: #1e293b;
word-break: break-word;
}
}
.ppv__close {
padding: 6px 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
@@ -1,78 +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,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import { productCategoryLabel, productTypeLabel } from '../loan-product-types';
/** Submitted values that carry no information for a reader. */
const HIDDEN_KEYS = new Set([
'locale',
'dateFormat',
'charges'
]);
/**
* Read-only view of a loan product request that is still awaiting a checker. The product does not exist yet, so the
* page is built from what the maker submitted (the request payload).
*/
@Component({
selector: 'mifosx-pending-product-view-dialog',
templateUrl: './pending-product-view-dialog.component.html',
styleUrls: ['./pending-product-view-dialog.component.scss'],
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class PendingProductViewDialogComponent {
/** The pending table row: name, shortName, madeBy and the submitted payload. */
data = inject<any>(MAT_DIALOG_DATA);
get category(): string {
return productCategoryLabel(this.data?.payload?.productTypeGroup);
}
get type(): string {
return productTypeLabel(this.data?.payload?.productTypeGroup, this.data?.payload?.productTypeSubGroup);
}
/** Every submitted field as label / value, hiding empty values and technical keys. */
get fields(): { label: string; value: string }[] {
const payload = this.data?.payload ?? {};
return Object.keys(payload)
.filter((key) => !HIDDEN_KEYS.has(key))
.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== '')
.map((key) => ({ label: this.label(key), value: this.format(payload[key]) }));
}
private label(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
private format(value: any): string {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
return value.length
? value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', ')
: '—';
}
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
}
@@ -48,14 +48,6 @@
</div>
</div>
@if (advanceReview) {
<mifosx-loan-product-preview-step
[loanProduct]="advanceReview.product"
[loanProductsTemplate]="advanceReview.template"
[readOnly]="true"
></mifosx-loan-product-preview-step>
}
<!-- Summary content (rendered by shared component) -->
<div class="pv-legacy">
<mifosx-loan-product-summary
@@ -8,7 +8,6 @@
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { LoanProduct } from '../../models/loan-product.model';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
@@ -20,8 +19,6 @@ import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductSummaryComponent } from '../../common/loan-product-summary/loan-product-summary.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductPreviewStepComponent } from '../../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
import { reviewProductOf } from '../../loan-product-original.util';
@Component({
selector: 'mifosx-general-tab',
@@ -30,8 +27,7 @@ import { reviewProductOf } from '../../loan-product-original.util';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
LoanProductSummaryComponent,
LoanProductPreviewStepComponent
LoanProductSummaryComponent
]
})
export class GeneralTabComponent implements OnInit {
@@ -41,11 +37,8 @@ export class GeneralTabComponent implements OnInit {
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private translateService = inject(TranslateService);
private http = inject(HttpClient);
loanProduct: LoanProduct;
/** An Advances product: its own settings live in the extension record the standard summary does not read. */
advanceReview: { product: any; template: any } | null = null;
constructor() {
this.route.data.subscribe((data: { loanProduct: any }) => {
@@ -54,45 +47,11 @@ export class GeneralTabComponent implements OnInit {
}
ngOnInit() {
const savedGroup = (this.loanProduct as any)?.advanceExtensions?.productTypeGroup;
if (savedGroup === 'advances' || savedGroup === 'loans') {
this.advanceReview = reviewProductOf(this.loanProduct);
this.nameSuspenseGl();
}
this.loanProduct.allowAttributeConfiguration = Object.values(this.loanProduct.allowAttributeOverrides).some(
(attribute: boolean) => attribute
);
}
/** The interest suspense GL lives on the extension record, not in the accounting mappings: look its name up. */
private nameSuspenseGl(): void {
const id = this.advanceReview?.product?.undebitedInterestGlId;
if (id === undefined || id === null) {
return;
}
this.http.get(`/glaccounts/${id}`).subscribe({
next: (account: any) => {
const current = this.advanceReview;
const options = current.template.accountingMappingOptions;
// A new object, so the Review reader rebuilds with the name.
this.advanceReview = {
product: { ...current.product },
template: {
accountingMappingOptions: {
...options,
assetAccountOptions: [
...options.assetAccountOptions,
{ id: account.id, glCode: account.glCode, name: account.name }]
}
}
};
},
error: () => {
/* the id stays as the fallback text */
}
});
}
exportDefinition(): void {
const product = this.loanProduct;
delete product['id'];
@@ -48,17 +48,6 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" required formControlName="maximumAgeDays" min="1" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -61,18 +61,15 @@ export class CreateRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*)$'),
Validators.max(10000)
]
],
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: ['']
]
});
}
submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code;
const data: any = {
const data = {
...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale
};
this.productsService.createDelinquencyRange(data).subscribe((response: any) => {
@@ -47,17 +47,6 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" formControlName="maximumAgeDays" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -68,18 +68,15 @@ export class EditRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*?)$'),
Validators.max(10000)
]
],
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: [this.delinquencyRangeData.stage ?? '']
]
});
}
submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code;
const data: any = {
const data = {
...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale
};
this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => {
@@ -52,25 +52,6 @@
<span class="vr-label">{{ 'labels.inputs.Days Till' | translate }}</span>
<span class="vr-value">{{ delinquencyRangeData.maximumAgeDays }}</span>
</div>
<div class="vr-row">
<span class="vr-label">Stage (RBI 2026 ECL)</span>
<span class="vr-value">
@switch (delinquencyRangeData.stage) {
@case (1) {
Stage 1 — Standard / performing
}
@case (2) {
Stage 2 — SICR / underperforming
}
@case (3) {
Stage 3 — NPA / credit-impaired
}
@default {
— None (informational only) —
}
}
</span>
</div>
</div>
</div>
</div>
@@ -34,16 +34,5 @@
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
<div class="mdb-card" [routerLink]="['floor-master']" *mifosxHasPermission="'READ_PRUDENTIAL_FLOOR'">
<div class="mdb-card__icon-wrap">
<i class="fas fa-chart-line"></i>
</div>
<div class="mdb-card__content">
<h3>Prudential Floor Master</h3>
<p>RBI 2026 ECL stage-wise provisioning floor, by product category</p>
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
</div>
</div>
@@ -1,64 +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="container">
<div class="table-header">
<div class="table-title-group">
<h2 class="table-title">
<i class="fas fa-chart-line title-icon"></i>
Prudential Floor Master
</h2>
<p class="pf-subtitle">
RBI 2026 ECL framework (para 82) — regulatory minimum provisioning floor by product category and stage. Seeded
from the RBI Directions; read-only.
</p>
</div>
<div class="table-actions">
<div class="table-search">
<mat-form-field appearance="outline">
<i matPrefix class="fas fa-search"></i>
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
</div>
</div>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
<ng-container matColumnDef="productCategory">
<th mat-header-cell *matHeaderCellDef mat-sort-header="productCategory">Product Category</th>
<td mat-cell *matCellDef="let row">{{ row.productCategory }}</td>
</ng-container>
<ng-container matColumnDef="stage">
<th mat-header-cell *matHeaderCellDef mat-sort-header="stage">Stage</th>
<td mat-cell *matCellDef="let row">{{ stageLabels[row.stage] ?? row.stage }}</td>
</ng-container>
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef>Duration in Stage 3</th>
<td mat-cell *matCellDef="let row">{{ durationLabel(row) }}</td>
</ng-container>
<ng-container matColumnDef="securedFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="securedFloorPct">Secured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.securedFloorPct }}</td>
</ng-container>
<ng-container matColumnDef="unsecuredFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="unsecuredFloorPct">Unsecured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.unsecuredFloorPct }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -1,32 +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 { Injectable, inject } from '@angular/core';
/** rxjs Imports */
import { Observable } from 'rxjs';
/** Custom Services */
import { ProductsService } from '../../products.service';
/**
* Prudential Floor Master data resolver (RBI 2026 ECL framework, para 82). Read-only.
*/
@Injectable()
export class PrudentialFloorComponentsResolver {
private productsService = inject(ProductsService);
/**
* Returns the prudential floor master rows.
* @returns {Observable<any>}
*/
resolve(): Observable<any> {
return this.productsService.getPrudentialFloors();
}
}
@@ -1,19 +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 '../../../shared/styles/list-layout' as *;
table {
width: 100%;
}
.pf-subtitle {
margin: 4px 0 0;
color: rgb(0 0 0 / 60%);
font-size: 0.875rem;
}
@@ -1,118 +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, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { ActivatedRoute } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* RBI 2026 ECL framework: stage-wise prudential floor master (para 82), by loan product category.
* Read-only — the master is seeded from the RBI Directions, not created per bank, so there is no
* create/edit screen here (a Board-approved "tighten only" override is a follow-up, see backend
* PrudentialFloorsApiResource).
*/
@Component({
selector: 'mifosx-prudential-floor',
templateUrl: './prudential-floor.component.html',
styleUrls: ['./prudential-floor.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator
]
})
export class PrudentialFloorComponent implements OnInit {
private route = inject(ActivatedRoute);
prudentialFloorData: any[];
/** Columns to be displayed in the prudential floor table. */
displayedColumns: string[] = [
'productCategory',
'stage',
'duration',
'securedFloorPct',
'unsecuredFloorPct'
];
/** Data source for the prudential floor table. */
dataSource: MatTableDataSource<any>;
/** Paginator for the prudential floor table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for the prudential floor table. */
@ViewChild(MatSort, { static: true }) sort: MatSort;
readonly stageLabels: Record<number, string> = {
1: 'Stage 1',
2: 'Stage 2',
3: 'Stage 3'
};
constructor() {
this.route.data.subscribe((data: { prudentialFloors: any[] }) => {
this.prudentialFloorData = data.prudentialFloors;
});
}
ngOnInit(): void {
this.setDatasource();
}
/**
* Filters data in the prudential floor table based on passed value.
* @param {string} filterValue Value to filter data.
*/
applyFilter(filterValue: string) {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** "0–365 days", "1461+ days" (open-ended) or "—" (Stage 1/2: not duration-banded). */
durationLabel(row: any): string {
if (row.durationFromDays == null) {
return '—';
}
if (row.durationToDays == null) {
return `${row.durationFromDays}+ days`;
}
return `${row.durationFromDays}–${row.durationToDays} days`;
}
/**
* Initializes the data source, paginator and sorter for the prudential floor table.
*/
setDatasource() {
this.dataSource = new MatTableDataSource(this.prudentialFloorData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
}
@@ -120,8 +120,6 @@ import { DelinquencyBucketComponent } from './manage-delinquency-buckets/delinqu
import { DelinquencyRangeComponent } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component';
import { DelinquencyBucketComponentsResolver } from './manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.resolver';
import { DelinquencyRangeComponentsResolver } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component.resolver';
import { PrudentialFloorComponent } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component';
import { PrudentialFloorComponentsResolver } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver';
import { ViewRangeComponent } from './manage-delinquency-buckets/delinquency-range/view-range/view-range.component';
import { EditRangeComponent } from './manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component';
import { CreateRangeComponent } from './manage-delinquency-buckets/delinquency-range/create-range/create-range.component';
@@ -226,15 +224,6 @@ const routes: Routes = [
},
{
path: 'edit',
component: CreateLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: {
loanProductAndTemplate: LoanProductAndTemplateResolver,
configurations: GlobalConfigurationsResolver
}
},
{
path: 'edit-legacy',
component: EditLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: {
@@ -674,14 +663,6 @@ const routes: Routes = [
]
}
]
},
{
path: 'floor-master',
component: PrudentialFloorComponent,
data: { title: 'Prudential Floor Master', breadcrumb: 'Prudential Floor Master' },
resolve: {
prudentialFloors: PrudentialFloorComponentsResolver
}
}
]
},
@@ -1123,7 +1104,6 @@ const routes: Routes = [
CollateralTemplateResolver,
DelinquencyRangeComponentsResolver,
DelinquencyBucketComponentsResolver,
PrudentialFloorComponentsResolver,
CurrentProductsResolver,
CurrentProductsTemplateResolver,
CurrentProductResolver,
+2 -41
View File
@@ -12,8 +12,7 @@ import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { Observable } from 'rxjs';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
@@ -584,17 +583,6 @@ export class ProductsService {
return this.http.put(`/taxes/group/${taxGroupId}`, taxGroup);
}
/**
* RBI 2026 ECL framework: stage-wise prudential floor master, by product category. Read-only — seeded
* from the RBI Directions (para 82) rather than created per bank.
* @param {string} [productCategory] Optional filter.
* @returns {Observable<any>} Prudential Floor rows
*/
getPrudentialFloors(productCategory?: string): Observable<any> {
const params = productCategory ? { productCategory } : {};
return this.http.get('/prudentialfloors', { params });
}
/**
* @returns {Observable<any>} Delinquency Range data
*/
@@ -879,36 +867,9 @@ export class ProductsService {
}
/** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */
/**
* Pending requests for an entity: everything the user may approve (checker inbox) plus the requests the user
* submitted. The inbox alone hides a maker's own requests unless the maker also holds checker permission.
* Each entry carries canApprove, true only for those the user can actually approve.
*/
getPendingMakerCheckerByEntity(entityName: string): Observable<any> {
const params = new HttpParams().set('entityName', entityName);
const context = new HttpContext().set(SILENT_HTTP_ERROR, true);
const toApprove$ = this.http
.get<any[]>('/makercheckers', { params, context })
.pipe(catchError(() => of([] as any[])));
const mine$ = this.http
.get<any[]>('/makercheckers/mine', { params, context })
.pipe(catchError(() => of([] as any[])));
return forkJoin([
toApprove$,
mine$
]).pipe(
map(
([
toApprove,
mine
]) => {
const byId = new Map<number, any>();
(mine ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: false }));
(toApprove ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: true }));
return [...byId.values()];
}
)
);
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
}
/** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
@@ -11,12 +11,10 @@ import { Injectable, inject } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
/** rxjs Imports */
import { Observable, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { Observable } from 'rxjs';
/** Custom Services */
import { SavingsService } from '../savings.service';
import { VoucherCorrectionService } from '../../cashier/voucher-correction.service';
/**
* Savings Account data resolver.
@@ -24,29 +22,14 @@ import { VoucherCorrectionService } from '../../cashier/voucher-correction.servi
@Injectable()
export class SavingsAccountViewResolver {
private savingsService = inject(SavingsService);
private voucherCorrectionService = inject(VoucherCorrectionService);
/**
* Returns the Savings Account data, without the deposits a completed voucher correction took off this account:
* the passbook and the transaction list read their rows from here, and a deposit that was posted to the wrong
* account and moved should not appear on it at all.
* Returns the Savings Account data.
* @param {ActivatedRouteSnapshot} route Route Snapshot
* @returns {Observable<any>}
*/
resolve(route: ActivatedRouteSnapshot): Observable<any> {
const savingAccountId = route.paramMap.get('savingAccountId');
return this.savingsService.getSavingsAccountData(savingAccountId).pipe(
switchMap((account: any) =>
this.voucherCorrectionService.voidedTransactions(Number(savingAccountId)).pipe(
catchError(() => of([] as number[])),
map((voided: number[]) => {
if (voided.length && Array.isArray(account?.transactions)) {
account.transactions = account.transactions.filter((txn: any) => !voided.includes(txn.id));
}
return account;
})
)
)
);
return this.savingsService.getSavingsAccountData(savingAccountId);
}
}
@@ -63,90 +63,9 @@ export class ProductEditDiffDialogComponent implements OnInit {
'id',
'productId',
'savingsProductId',
'otherGlMappings',
// Loan / advance engine defaults and the locked type: not settings a reviewer decides on.
'amortizationType',
'interestType',
'interestCalculationPeriodType',
'transactionProcessingStrategyCode',
'daysInYearType',
'daysInMonthType',
'isInterestRecalculationEnabled',
'repaymentEvery',
'repaymentFrequencyType',
'interestRateFrequencyType',
'accountingRule',
'productTypeGroup',
'productTypeSubGroup'
'otherGlMappings'
]);
/** Plain names for the loan / advance fields that otherwise read as raw parameter names. */
private static readonly FRIENDLY_LABELS: Record<string, string> = {
minPrincipal: 'Minimum amount / limit',
principal: 'Default amount / limit',
maxPrincipal: 'Maximum amount / limit',
minNumberOfRepayments: 'Minimum tenure (months)',
numberOfRepayments: 'Default tenure (months)',
maxNumberOfRepayments: 'Maximum tenure (months)',
interestRatePerPeriod: 'Default interest rate (%)',
minInterestRatePerPeriod: 'Minimum interest rate (%)',
maxInterestRatePerPeriod: 'Maximum interest rate (%)',
overdueDaysForNPA: 'NPA after (days overdue)',
graceOnArrearsAgeing: 'Arrears grace (days)',
smaStartAfterMonths: 'SMA counting starts after (months)',
penalRate: 'Penal rate (%)',
margin: 'Margin (%)',
ltv: 'LTV (%)',
glAccount: 'Product GL (asset)',
registerParticular: 'Rate register row',
loanPortfolioAccountId: 'Loan portfolio GL',
fundSourceAccountId: 'Fund source GL',
interestOnLoanAccountId: 'Interest income GL',
incomeFromFeeAccountId: 'Fee income GL',
incomeFromPenaltyAccountId: 'Penal income GL',
writeOffAccountId: 'Write-off GL',
overpaymentLiabilityAccountId: 'Overpayment GL',
receivableInterestAccountId: 'Interest receivable GL',
undebitedInterestGlId: 'Interest suspense GL',
goldMaxLtv: 'Gold: maximum LTV cap (%)',
goldMinPurityCarat: 'Gold: minimum purity (carat)',
goldRateSource: 'Gold: valuation rate source',
goldMaxPerGram: 'Gold: max loan per gram (₹)',
goldMinNetWeight: 'Gold: minimum net weight (g)',
goldMaxNetWeightPerCustomer: 'Gold: max net weight per customer (g)',
goldAllowedItems: 'Gold: allowed items',
goldAppraiserRequired: 'Gold: appraiser required',
goldValuationCertificateRequired: 'Gold: valuation certificate required',
goldMaxValuationPerAppraiser: 'Gold: max valuation per appraiser (₹)',
goldPacketPrefix: 'Gold: packet number prefix',
goldRevalueOnNpa: 'Gold: revalue when loan turns NPA',
goldRevaluationChargeApplicable: 'Gold: charge customer for revaluation',
insuranceRequired: 'Insurance required',
goldAuctionNoticeDays: 'Gold: auction notice (days)',
goldPartialReleaseAllowed: 'Gold: partial release of ornaments',
goldThirdPartyAllowed: 'Gold: third-party gold allowed',
goldDisbursementMode: 'Gold: disbursement mode',
goldCashDisbursementLimit: 'Gold: cash disbursement limit (₹)',
goldLtvBreachAlert: 'Gold: LTV breach alert (%)',
goldAuctionReservePct: 'Gold: auction reserve price (%)',
goldRedeemBeforeAuction: 'Gold: customer may redeem before auction',
wrMaxLtv: 'Warehouse: maximum LTV cap (%)',
wrReceiptType: 'Warehouse: receipt type allowed',
wrWdraRegistered: 'Warehouse: WDRA-registered warehouse',
wrAssayCertificateRequired: 'Warehouse: assay certificate required',
wrTenureWithinReceiptValidity: 'Warehouse: tenure within receipt validity',
wrPledgeMarkedBeforeDisbursement: 'Warehouse: pledge marked before disbursement',
wrCommodityGroups: 'Warehouse: eligible commodity groups',
wrValuationPriceSource: 'Warehouse: valuation price source',
wrMaxLoanPerQuintal: 'Warehouse: max loan per quintal (₹)',
wrStockInspectionMonths: 'Warehouse: stock inspection (months)',
wrLtvBreachAlert: 'Warehouse: LTV breach alert (%)',
wrPartialReleaseAllowed: 'Warehouse: partial release of stock',
wrDisposalNoticeDays: 'Warehouse: disposal notice (days)',
wrDisposalReservePct: 'Warehouse: disposal reserve price (%)',
wrRedeemBeforeDisposal: 'Warehouse: customer may redeem before disposal'
};
/** The two reserved otherGlMappings entries a checker actually needs to see by name -- see
* AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any
* other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no
@@ -165,7 +84,6 @@ export class ProductEditDiffDialogComponent implements OnInit {
digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id',
delinquencyBucketId: 'delinquencyBucket.id',
// Enum fields the stored product exposes as "<name>Type": {id, value}", not "<name>TypeId" --
// without these the "current" side always read as blank, so these showed as changed on every
// edit even when the maker never touched them.
@@ -206,13 +124,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
penaltiesReceivableAccountId: 'penaltyReceivableAccount',
interestPayableAccountId: 'interestPayableAccount',
interestReceivableAccountId: 'interestReceivableAccount',
escheatLiabilityId: 'escheatLiabilityAccount',
// loan / advance products
fundSourceAccountId: 'fundSourceAccount',
loanPortfolioAccountId: 'loanPortfolioAccount',
interestOnLoanAccountId: 'interestOnLoanAccount',
overpaymentLiabilityAccountId: 'overpaymentLiabilityAccount',
receivableInterestAccountId: 'receivableInterestAccount'
escheatLiabilityId: 'escheatLiabilityAccount'
};
private glAccountNamesById = new Map<string, string>();
@@ -231,7 +143,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
.map((key) => ({ key, originalValue: this.originalValueFor(key, original) }))
.filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({
label: ProductEditDiffDialogComponent.FRIENDLY_LABELS[key] ?? humanizeKey(key),
label: humanizeKey(key),
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
@@ -144,24 +144,6 @@
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span>
</a>
<a
class="ac-tab"
routerLink="./cash-transfers"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
<span>Cash Transfers</span>
</a>
<a
class="ac-tab"
routerLink="./voucher-corrections"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
<span>Voucher Corrections</span>
</a>
<a
class="ac-tab"
routerLink="./share-withdrawal-requests"
@@ -16,7 +16,7 @@ $green: #10b981;
$amber: #f59e0b;
.ac-container {
padding: 12px 20px;
padding: 20px 24px;
max-width: 1440px;
margin: 0 auto;
}
@@ -25,40 +25,40 @@ $amber: #f59e0b;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
&__left {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
}
&__icon {
width: 32px;
height: 32px;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border-radius: 12px;
background: linear-gradient(135deg, $blue 0%, $purple 100%);
color: #fff;
font-size: 14px;
font-size: 18px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
}
&__title {
margin: 0;
font-size: 16px;
font-size: 20px;
font-weight: 800;
color: $navy;
line-height: 1.1;
}
&__sub {
margin-top: 1px;
font-size: 11px;
margin-top: 2px;
font-size: 12.5px;
color: #64748b;
}
}
@@ -68,26 +68,26 @@ $amber: #f59e0b;
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
click away instead of a scroll-and-hunt. */
.ac-tabs {
margin-bottom: 12px;
margin-bottom: 20px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 4px;
border-radius: 14px;
padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
display: flex;
flex-wrap: wrap;
gap: 2px;
gap: 4px;
}
.ac-tab {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 8px;
gap: 8px;
height: 38px;
padding: 0 18px;
border-radius: 10px;
font-family: Inter, 'DM Sans', Roboto, sans-serif;
font-size: 11.5px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: #64748b;
@@ -99,7 +99,7 @@ $amber: #f59e0b;
cursor: pointer;
&__ic {
font-size: 11px;
font-size: 13px;
color: inherit;
}
@@ -118,7 +118,7 @@ $amber: #f59e0b;
@media (width <= 768px) {
.ac-tab {
padding: 0 10px;
font-size: 11px;
padding: 0 14px;
font-size: 12px;
}
}
-4
View File
@@ -127,10 +127,6 @@ export function normalizeValue(value: any): any {
if (typeof value === 'boolean') {
return value;
}
// A YES / NO select is saved as true / false: the same answer, so compare them as one.
if (typeof value === 'string' && /^(yes|no)$/i.test(value.trim())) {
return value.trim().toLowerCase() === 'yes';
}
const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber;
}
+1 -3
View File
@@ -7,8 +7,6 @@
*/
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { withExtensions } from 'app/products/loan-products/loan-product-original.util';
import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
@@ -61,7 +59,7 @@ const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Ob
SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true),
CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true),
SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true).pipe(map((product: any) => withExtensions(product))),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true),
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true),
-16
View File
@@ -194,22 +194,6 @@ const routes: Routes = [
component: ShareWithdrawalRequestsComponent,
data: { title: 'Share Withdrawal Requests' }
},
{
path: 'cash-transfers',
loadComponent: () =>
import('../cashier/cash-transfer-approvals/cash-transfer-approvals.component').then(
(m) => m.CashTransferApprovalsComponent
),
data: { title: 'Cash Transfers' }
},
{
path: 'voucher-corrections',
loadComponent: () =>
import('../cashier/voucher-correction-approvals/voucher-correction-approvals.component').then(
(m) => m.VoucherCorrectionApprovalsComponent
),
data: { title: 'Voucher Corrections' }
},
{
path: 'dividend-requests',
component: DividendRequestsComponent,

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