Revert demo to pre-2026-08-06 state (rollback today's push)

Reverts all commits pushed to demo today (a5124ca3, 858c277a, a1a50ab8,
f72abb09, fcf66657) back to f8c532f7 as a single forward commit, without
rewriting history. All reverted content (Share Withdrawal/Closure feature,
TDS reports, KYC exception tracking, client-edit-email-dialog, form121 tab,
search-select) remains intact on dev and was verified identical before
this revert.
This commit is contained in:
2026-08-06 13:39:40 +05:30
parent fcf6665772
commit 14c5f5070d
87 changed files with 1102 additions and 14920 deletions
+1 -1
View File
@@ -70,7 +70,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "15mb", "maximumWarning": "15mb",
"maximumError": "16mb" "maximumError": "17mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
@@ -327,11 +327,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}, },
error: (err: any) => { error: (err: any) => {
this.marking.delete(entry.id); this.marking.delete(entry.id);
// Fineract wraps the real reason in errors[0]; the outer defaultUserMessage
// is the generic "Errors contain reason for domain rule violation", which
// tells the reviewer nothing.
this.markError = this.markError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not mark this entry.'; err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
} }
}); });
} }
-16
View File
@@ -62,22 +62,6 @@ const routes: Routes = [
), ),
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' } data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
}, },
{
path: 'share-withdrawal-request',
loadComponent: () =>
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
(m) => m.ShareWithdrawalRequestComponent
),
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
},
{
path: 'share-withdrawal-requests',
loadComponent: () =>
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
(m) => m.MyShareWithdrawalRequestsComponent
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{ {
path: 'account-facilities', path: 'account-facilities',
loadComponent: () => loadComponent: () =>
@@ -1,53 +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';
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
@Injectable({ providedIn: 'root' })
export class ClerkShareWithdrawalService {
private http = inject(HttpClient);
submit(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests', data);
}
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
}
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-withdrawal-requests', { params });
}
getById(id: number): Observable<any> {
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
}
validate(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
}
}
@@ -146,7 +146,7 @@
[class.ws-f__ctl--req]="!row.paymentTypeId" [class.ws-f__ctl--req]="!row.paymentTypeId"
> >
<option [ngValue]="null">— Select —</option> <option [ngValue]="null">— Select —</option>
@for (pt of paymentTypesForRow(row); track pt.id) { @for (pt of paymentTypes; track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option> <option [ngValue]="pt.id">{{ pt.name }}</option>
} }
</select> </select>
@@ -231,11 +231,8 @@
<span class="ws-caption">Payment instrument</span> <span class="ws-caption">Payment instrument</span>
<div class="ws-group__fields"> <div class="ws-group__fields">
<div class="ws-f ws-f--amt"> <div class="ws-f ws-f--amt">
<label <label class="ws-f__lbl" title="Transaction Amount">
class="ws-f__lbl" AMOUNT
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
>
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
@if (!isClerk && activeWithdrawalRequest) { @if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span> <span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
} }
@@ -245,48 +242,20 @@
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row" [class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest" [class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
> >
@if (row.acTyp !== 'SHR') { <span class="ws-amt-prefix">₹</span>
<span class="ws-amt-prefix">₹</span>
}
<input <input
class="ws-f__ctl ws-amt-input" class="ws-f__ctl ws-amt-input"
type="number" type="number"
[value]="row.amount ?? ''" [value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (input)="row.amount = +$any($event.target).value"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" placeholder="0.00"
[readonly]="!isClerk && !!activeWithdrawalRequest" [readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest" [class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/> />
</div> </div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
<span class="ws-f__hint"
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
rowSharePrices[row.acNo] | number: '1.2-2'
}}/share</span
>
}
</div> </div>
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') { @if (row.typ !== 'Withdrawal Slip') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
<input
class="ws-f__ctl"
[(ngModel)]="row.resolutionNumber"
(click)="$event.stopPropagation()"
placeholder="e.g. BR-2026-014"
/>
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
<input
class="ws-f__ctl"
type="date"
[(ngModel)]="row.meetingDate"
(click)="$event.stopPropagation()"
/>
</div>
} @else if (row.typ !== 'Withdrawal Slip') {
<div class="ws-f ws-f--cheqno"> <div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label> <label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
<input <input
@@ -743,32 +712,6 @@
'—' '—'
}}</span> }}</span>
</div> </div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-field">
<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">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 te-panel__header--mt"> <div class="te-panel__header te-panel__header--mt">
@@ -781,13 +724,7 @@
<div class="te-panel__body"> <div class="te-panel__body">
<div class="te-balance-row"> <div class="te-balance-row">
<span class="te-bal-num">1.</span> <span class="te-bal-num">1.</span>
<span class="te-bal-label">{{ <span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
selectedRow?.acTyp === 'SHR'
? 'Share Balance'
: accountDetails?.glCode
? 'GL Balance'
: 'Current Balance'
}}</span>
<span class="te-bal-value te-bal-value--cr" <span class="te-bal-value te-bal-value--cr"
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span >{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
> >
@@ -1344,7 +1281,7 @@
[class.ws-f__ctl--req]="!row.paymentTypeId" [class.ws-f__ctl--req]="!row.paymentTypeId"
> >
<option [ngValue]="null">— Select —</option> <option [ngValue]="null">— Select —</option>
@for (pt of paymentTypesForRow(row); track pt.id) { @for (pt of paymentTypes; track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option> <option [ngValue]="pt.id">{{ pt.name }}</option>
} }
</select> </select>
@@ -1423,7 +1360,7 @@
<div class="ws-group__fields"> <div class="ws-group__fields">
<div class="ws-f ws-f--amt"> <div class="ws-f ws-f--amt">
<label class="ws-f__lbl"> <label class="ws-f__lbl">
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }} AMOUNT
@if (!isClerk && activeWithdrawalRequest) { @if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span> <span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
} }
@@ -1433,51 +1370,27 @@
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row" [class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest" [class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
> >
@if (row.acTyp !== 'SHR') { <span class="ws-amt-prefix">₹</span>
<span class="ws-amt-prefix">₹</span>
}
<input <input
class="ws-f__ctl ws-amt-input" class="ws-f__ctl ws-amt-input"
type="number" type="number"
[value]="row.amount ?? ''" [value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (input)="row.amount = +$any($event.target).value"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" placeholder="0.00"
[readonly]="!isClerk && !!activeWithdrawalRequest" [readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest" [class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/> />
</div> </div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
<span class="ws-f__hint"
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
rowSharePrices[row.acNo] | number: '1.2-2'
}}/share</span
>
}
</div> </div>
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') { <div class="ws-f ws-f--cheqno">
<div class="ws-f ws-f--cheqno"> <label class="ws-f__lbl">CHEQUE NO.</label>
<label class="ws-f__lbl">RESOLUTION NO.</label> <input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
<input </div>
class="ws-f__ctl" <div class="ws-f ws-f--cheqdt">
[(ngModel)]="row.resolutionNumber" <label class="ws-f__lbl">CHEQ. DATE</label>
placeholder="e.g. BR-2026-014" <input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
/> </div>
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">MEETING DATE</label>
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
</div>
} @else {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">CHEQUE NO.</label>
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">CHEQ. DATE</label>
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
</div>
}
<div class="ws-f ws-f--tok"> <div class="ws-f ws-f--tok">
<label class="ws-f__lbl">TOK. NO.</label> <label class="ws-f__lbl">TOK. NO.</label>
<input <input
@@ -961,14 +961,6 @@ input.ws-amt-input--locked {
flex-shrink: 0; flex-shrink: 0;
} }
.ws-f__hint {
display: block;
margin-top: 2px;
font-size: 10px;
color: $ws-muted;
font-weight: 500;
}
.ws-amt-input { .ws-amt-input {
flex: 1; flex: 1;
height: 100%; height: 100%;
@@ -21,7 +21,6 @@ import { AuthenticationService } from '../../core/authentication/authentication.
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service'; import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
import { ClerkWithdrawalService } from '../clerk-withdrawal.service'; import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service'; import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
import { CashierDepositService } from '../cashier-deposit.service'; import { CashierDepositService } from '../cashier-deposit.service';
import { SettingsService } from '../../settings/settings.service'; import { SettingsService } from '../../settings/settings.service';
import { import {
@@ -48,10 +47,6 @@ interface TxnRow {
routingCode: string; routingCode: string;
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null; ddCharges: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
} }
interface Denomination { interface Denomination {
@@ -758,7 +753,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private clerkUiState = inject(ClerkUiStateService); private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService); private clerkWithdrawalService = inject(ClerkWithdrawalService);
private clerkDisbursementService = inject(ClerkDisbursementService); private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
private cashierDepositService = inject(CashierDepositService); private cashierDepositService = inject(CashierDepositService);
readonly router = inject(Router); readonly router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
@@ -775,38 +769,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
loadingAccount = false; loadingAccount = false;
accountError = ''; accountError = '';
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
* Every other account type/mode keeps the full, unfiltered list. */
paymentTypesForRow(row: TxnRow): any[] {
if (row.acTyp === 'SHR') {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
const isShareSettlementCredit =
row.typ === 'Enter Bank Transfer' &&
row.dbCr === 'Cr' &&
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
if (isShareSettlementCredit) {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
return this.paymentTypes;
}
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement) // Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
rowAccountIds: Record<string, number> = {}; rowAccountIds: Record<string, number> = {};
rowAccountNames: Record<string, string> = {}; rowAccountNames: Record<string, string> = {};
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
rowSharePrices: Record<string, number> = {};
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
// that isn't necessarily the currently "selected" one.
rowClientIds: Record<string, number> = {};
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
rowLinkedSavingsAcNo: Record<string, string> = {};
// Full account + client data // Full account + client data
accountDetails: any = null; accountDetails: any = null;
@@ -1240,9 +1205,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}; };
this.rows.push(mirror); this.rows.push(mirror);
this.selectedRow = mirror; this.selectedRow = mirror;
if (active.acTyp === 'SHR') {
this.syncShareLinkedAmounts();
}
return; return;
} }
const row: TxnRow = { const row: TxnRow = {
@@ -1537,182 +1499,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}); });
} }
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
* approval both redeems the shares and settles the linked account automatically. */
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
// that filter applied — settlement is always a book-transfer to the linked savings account, and
// it's the CR row's payment type that actually governs how that credit posts, never the DR
// (Share) row's, which has no real payment-type concept.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
if (!savingsRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
]);
return;
}
if (settlementPaymentType?.isCashPayment) {
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
]);
return;
}
const shareAccountId = this.rowAccountIds[shareRow.acNo];
if (!shareAccountId) {
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
return;
}
if (!this.rowAccountIds[savingsRow.acNo]) {
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
return;
}
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
const enteredAcNo = savingsRow.acNo?.trim();
const matchesLinked =
!!linkedAcNo &&
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
if (linkedAcNo && !matchesLinked) {
this.showAlert('error', 'Wrong Credit Account', [
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
`You entered: ${enteredAcNo}`
]);
return;
}
if (!linkedAcNo) {
this.showAlert('error', 'No Linked Savings Account', [
'This Share account has no linked savings account configured — settlement has nowhere to go.'
]);
return;
}
const shares = Number(shareRow.amount) || 0;
if (shares <= 0) {
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
return;
}
if (!shareRow.resolutionNumber?.trim()) {
this.showAlert('error', 'Board Resolution Number Required', [
'A share withdrawal must be authorised by a Board Resolution.'
]);
return;
}
if (!shareRow.meetingDate) {
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
return;
}
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
// these fields are not re-read by the backend, they are the audit record of what was on screen.
const settlementRowSnapshot = {
branch: savingsRow.br,
accountType: savingsRow.acTyp,
accountNo: savingsRow.acNo,
ecsAccountNo: savingsRow.ecsAcNo || null,
dbCr: savingsRow.dbCr,
amount: savingsRow.amount ?? null,
chequeNo: savingsRow.chequeNo || null,
chequeDate: savingsRow.cheqDate || null,
tokenNo: savingsRow.tokNo || null,
description: savingsRow.description || null,
bankName: savingsRow.bankName || null,
routingCode: savingsRow.routingCode || null,
paymentTypeId: savingsRow.paymentTypeId,
paymentTypeName: settlementPaymentType?.name ?? null
};
const payload = {
shareAccountId,
accountNo: shareRow.acNo,
clientId: this.rowClientIds[shareRow.acNo] ?? null,
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
requestedShares: shares,
requestedDate,
resolutionNumber: shareRow.resolutionNumber.trim(),
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementRowSnapshot
};
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
const confirmLines = [
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Board Resolution No.: ${payload.resolutionNumber}`,
`Date of Meeting: ${payload.meetingDate}`,
`Settlement Credit To: ${linkedAcNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []),
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
'',
'Validation checks:',
` ✓ Share account and linked savings account both loaded`,
` ✓ CR account matches this Share account's linked savings account`,
` ✓ Settlement Payment Type is non-cash`,
` ✓ Board Resolution Number and Date of Meeting provided`,
'',
'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
private doSubmitShareRedemptionTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
shares: number,
linkedAcNo: string
): void {
this.saving = true;
this.clerkShareWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Share withdrawal submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert('success', 'Share Withdrawal Submitted', [
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
'Pending Manager approval. Track status in "My Share Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.rowSharePrices = {};
this.rowClientIds = {};
this.rowLinkedSavingsAcNo = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */ /** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
onTokNoBlur(row: TxnRow): void { onTokNoBlur(row: TxnRow): void {
if (this.isClerk || !row.tokNo?.trim()) return; if (this.isClerk || !row.tokNo?.trim()) return;
@@ -1749,25 +1535,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}); });
} }
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
* convenience for filling the paired settlement entry. The Share row itself still can never be
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
* correct amount to carry over into the separate manual Deposit Slip entry. */
syncShareLinkedAmounts(): void {
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
if (shareRows.length !== 1) return;
const shareRow = shareRows[0];
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
const shares = Number(shareRow.amount) || 0;
const total = Math.round(shares * price * 100) / 100;
this.rows.forEach((r) => {
if (r !== shareRow) {
r.amount = total;
}
});
}
onAcNoBlur(row: TxnRow, index: number): void { onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return; if (!row.acNo.trim()) return;
this.selectedRow = row; this.selectedRow = row;
@@ -1866,14 +1633,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (token !== this.searchToken) return; // stale result — newer search started if (token !== this.searchToken) return; // stale result — newer search started
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []); const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField; const f = cfg.matchField;
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its // Exact match first, then suffix/partial match (backend may return by last-N-digits)
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
// gated on "exactly one row came back" — it never would be for those types.
const exactMatch = allItems.find((x: any) => x[f] === acNo); const exactMatch = allItems.find((x: any) => x[f] === acNo);
const partialMatch = !exactMatch const partialMatch =
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo))) !exactMatch && allItems.length === 1
: null; ? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
: null;
const match = exactMatch ?? partialMatch ?? null; const match = exactMatch ?? partialMatch ?? null;
if (match?.id) { if (match?.id) {
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
@@ -1894,17 +1659,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number; siAccountType: number;
txnBase: string; txnBase: string;
txnSearchSupported: boolean; txnSearchSupported: boolean;
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
* Share accounts — Fineract returns transactions embedded on the account detail response
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
* "today's transactions" HTTP call entirely instead of hitting a 404. */
txnFetchUnsupported?: boolean;
/** True when this account type has no standing-instruction "from account type" mapping on
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
* sending an fromAccountType value the backend's query-builder silently can't match,
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
siFetchUnsupported?: boolean;
maxLimit: number; maxLimit: number;
skipGlobalSearch: boolean; skipGlobalSearch: boolean;
listQueryParam: string; listQueryParam: string;
@@ -1939,8 +1693,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: 4, siAccountType: 4,
txnBase: '/accounts/share', txnBase: '/accounts/share',
txnSearchSupported: false, txnSearchSupported: false,
txnFetchUnsupported: true,
siFetchUnsupported: true,
maxLimit: 500, maxLimit: 500,
skipGlobalSearch: true, skipGlobalSearch: true,
listQueryParam: 'accountNo', listQueryParam: 'accountNo',
@@ -2053,8 +1805,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number; siAccountType: number;
txnBase: string; txnBase: string;
txnSearchSupported: boolean; txnSearchSupported: boolean;
txnFetchUnsupported?: boolean;
siFetchUnsupported?: boolean;
detailParams?: string; detailParams?: string;
}, },
token: number, token: number,
@@ -2087,29 +1837,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rowAccountIds[this.selectedRow.acNo] = acc.id; this.rowAccountIds[this.selectedRow.acNo] = acc.id;
this.rowAccountNames[this.selectedRow.acNo] = this.rowAccountNames[this.selectedRow.acNo] =
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? ''; acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
} }
if (this.selectedRow) { if (this.selectedRow) {
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
// is a different, unrelated account (the linked settlement savings account) and must
// never be shown here.
this.selectedRow.ecsAcNo =
acTyp?.toUpperCase() === 'SHR'
? (acc.accountNo ?? '')
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
} }
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp); if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
this.computeSameDayNeftRtgs(acc.transactions); this.computeSameDayNeftRtgs(acc.transactions);
this.computeSameDayChequeClearing(acc.transactions); this.computeSameDayChequeClearing(acc.transactions);
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
this.syncShareLinkedAmounts();
}
return true; return true;
}; };
@@ -2179,22 +1913,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))), documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))), notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))), linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
si: cfg.siFetchUnsupported si: this.http
? of({ pageItems: [] }) .get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
: this.http : this.http
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`) .get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] }))), .pipe(catchError(() => of({ pageItems: [] })))
todayTxns: cfg.txnFetchUnsupported
? of({ pageItems: [] })
: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
: this.http
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] })))
}); });
if (clientIdHint) { if (clientIdHint) {
@@ -2359,11 +2089,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// ── Balance getters ──────────────────────────────────────────────────────── // ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number { get currentBalance(): number {
if (this.selectedRow?.acTyp === 'SHR') {
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
return shares * unitPrice;
}
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0; return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
} }
@@ -2777,23 +2502,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return; return;
} }
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
if (shareDrRows.length > 0) {
if (drRows.length !== 1 || crRows.length !== 1) {
this.showAlert('error', 'Unsupported Share Transfer', [
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
]);
return;
}
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed // Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
const allowedTypes = new Set([ const allowedTypes = new Set([
'SD', 'SD',
@@ -3296,24 +3004,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const accountId = this.accountDetails.id; const accountId = this.accountDetails.id;
const cfg = this.accountTypeConfig(row.acTyp); const cfg = this.accountTypeConfig(row.acTyp);
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
// own numeric id, not a real savings account id. Approving that request would call
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
if (row.acTyp === 'SHR') {
this.showAlert('error', 'Not Supported for Share Accounts', [
isDeposit
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
]);
return;
}
// GL withdrawal → direct journal entry (no maker-checker) // GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below // GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) { if (row.acTyp === 'GL' && !isDeposit) {
-77
View File
@@ -1,77 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
* self-contained (this branch has no separate "investment" module to borrow them from). */
.inv-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.inv-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
input,
select,
textarea {
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
textarea {
height: auto;
padding: 8px 10px;
resize: vertical;
}
}
.inv-form-actions {
display: flex;
gap: 10px;
padding: 12px 16px 16px;
}
.inv-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
&--active {
background: #fef3c7;
color: #92400e;
}
&--matured {
background: #dcfce7;
color: #166534;
}
&--written_off {
background: #fee2e2;
color: #991b1b;
}
}
@@ -1,73 +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">CLERK · SHARE WITHDRAWAL</div>
<h1 class="pg-title">My Share Requests</h1>
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
</div>
</header>
<div class="pg-card">
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th class="num">Shares</th>
<th>Resolution No.</th>
<th>Meeting Date</th>
<th>Submitted At</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="7" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td class="num">{{ r.requested_shares }}</td>
<td>{{ r.resolution_number }}</td>
<td>{{ formatDateTime(r.meeting_date) }}</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@@ -1,112 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
@Component({
selector: 'mifosx-my-share-withdrawal-requests',
templateUrl: './my-share-withdrawal-requests.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class MyShareWithdrawalRequestsComponent implements OnInit {
private service = inject(ClerkShareWithdrawalService);
private router = inject(Router);
requests: any[] = [];
totalRecords = 0;
loading = false;
loaded = false;
filterStatus = '';
get statusOptions(): { value: string; label: string }[] {
return [
{ value: '', label: 'All' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getMyRequests(this.filterStatus || undefined, 0, 50)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
newRequest(): void {
this.router.navigate(['/clerk/share-withdrawal-request']);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off'
};
return `inv-badge ${map[status] ?? ''}`;
}
statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: 'Pending Approval',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return map[status] ?? status;
}
formatDateTime(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return String(val);
}
}
}
@@ -1,431 +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">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
<h1 class="pg-title">{{ pageTitle }}</h1>
<p class="pg-sub">{{ pageSubtitle }}</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
</button>
</div>
</header>
<div class="pg-card">
<div class="pos-section-label">Share Account</div>
<div class="inv-form-grid" style="padding: 16px">
@if (!isReview) {
<label class="inv-form-field" style="grid-column: span 2">
<span>* Search Account No. / Client Name</span>
<mifosx-search-select
[options]="accountOptions"
[(ngModel)]="selectedAccountId"
(ngModelChange)="onAccountSelected($event)"
placeholder="Search share account no. or client name…"
></mifosx-search-select>
</label>
}
@if (account) {
<label class="inv-form-field">
<span>Account No</span>
<input type="text" [value]="account.accountNo" readonly />
</label>
<label class="inv-form-field">
<span>Client</span>
<input type="text" [value]="account.clientName" readonly />
</label>
<label class="inv-form-field">
<span>Shares Held</span>
<input
type="text"
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
readonly
/>
</label>
<label class="inv-form-field">
<span>Unit Price</span>
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
</label>
}
@if (loadingAccount) {
<div class="pg-empty">Loading account…</div>
}
</div>
</div>
@if (isReview && reviewRequest) {
<div class="pg-card">
<div class="pos-section-label">Client Compliance</div>
<div class="inv-form-grid" style="padding: 16px">
<div class="inv-form-field">
<span>KYC Status</span>
<span
class="swr-chip"
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
>
{{ reviewRequest.client_kyc_status_label }}
</span>
</div>
<div class="inv-form-field">
<span>Risk Category</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
[class.swr-chip--warn]="reviewRequest.client_risk_high"
>
{{ reviewRequest.client_risk_category_label }}
</span>
</div>
<div class="inv-form-field">
<span>Open AML Alerts</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
>
{{ reviewRequest.client_open_aml_alerts || 0 }}
</span>
</div>
</div>
</div>
}
@if (account) {
<div class="pg-card">
<div class="pos-section-label">Withdrawal Details</div>
<div class="inv-form-grid" style="padding: 16px">
<label class="inv-form-field">
<span>* Shares to Withdraw</span>
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Requested Date</span>
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>Withdrawal Value</span>
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
</label>
<label class="inv-form-field">
<span>Remaining Shares After Withdrawal</span>
<input type="text" [value]="remainingShares" readonly />
</label>
<label class="inv-form-field">
<span>* Board Resolution Number</span>
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
</label>
<label class="inv-form-field">
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
<select [(ngModel)]="settlementPaymentTypeId">
<option [ngValue]="null">Select…</option>
@for (pt of paymentTypeOptions; track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
} @else {
<input type="text" [value]="settlementPaymentTypeName" readonly />
}
</label>
<label class="inv-form-field" style="grid-column: span 2">
<span>Description / Remarks</span>
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
</label>
</div>
@if (!isReview) {
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!isFormValid || submitting"
(click)="openConfirmDialog()"
>
{{ submitting ? 'Submitting…' : 'Review &amp; Submit' }}
</button>
</div>
}
</div>
}
@if (isReview && reviewRequest?.status === 'PENDING') {
<div class="pg-card">
<div class="pos-section-label">Verification</div>
<div class="swrv-trigger">
<div class="swrv-trigger__icon">
<mat-icon>fact_check</mat-icon>
</div>
<div class="swrv-trigger__text">
<div class="swrv-trigger__title">Run a live backend validation</div>
<div class="swrv-trigger__sub">
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
</div>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
<mat-icon>play_circle</mat-icon>
Validate Request
</button>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Process Withdrawal
</button>
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
<mat-icon>cancel</mat-icon>
Reject
</button>
</div>
</div>
}
@if (showValidateModal) {
<div class="swrv-modal">
<div class="swrv-modal__card">
<div class="swrv-modal__header">
<div class="swrv-modal__title">
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
Validate Share Withdrawal Request
</div>
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="swrv-modal__body">
<p class="swrv-modal__intro">
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
and GL configuration — not just what was captured at submission time.
</p>
@if (backendChecks && !validating && !validateAllPassed) {
<div class="swrv-modal__warn">
<mat-icon>warning</mat-icon>
<div>
<strong>Some checks need review before approving.</strong>
Scroll down to see which ones failed and why.
</div>
</div>
}
<ol class="swrv-steps">
@for (step of validateSteps; track step.label; let i = $index) {
<li
class="swrv-step"
[class.swrv-step--active]="step.status === 'active'"
[class.swrv-step--done]="step.status === 'done'"
[class.swrv-step--failed]="step.status === 'failed'"
>
<div class="swrv-step__indicator">
@if (step.status === 'done') {
<mat-icon>check</mat-icon>
} @else if (step.status === 'active') {
<mat-icon class="swrv-spin">autorenew</mat-icon>
} @else if (step.status === 'failed') {
<mat-icon>close</mat-icon>
} @else {
<span>{{ i + 1 }}</span>
}
</div>
<div class="swrv-step__content">
<div class="swrv-step__label">{{ step.label }}</div>
<div class="swrv-step__msg">{{ step.detail }}</div>
</div>
</li>
}
</ol>
@if (validating && validateSteps.length === 0) {
<div class="pg-empty">Connecting to server…</div>
}
@if (backendChecks && !validating && validateAllPassed) {
<div class="swrv-modal__result">
<mat-icon>verified</mat-icon>
<span>All checks passed — safe to approve.</span>
</div>
}
</div>
<div class="swrv-modal__footer">
@if (!validating && backendChecks) {
<button type="button" class="pg-btn" (click)="runBackendValidation()">
<mat-icon>refresh</mat-icon>
Re-run
</button>
}
@if (!validating && backendChecks && validateAllPassed) {
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Process Withdrawal
</button>
}
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
{{ validating ? 'Validating…' : 'Close' }}
</button>
</div>
</div>
</div>
}
@if (isReview && reviewRequest?.status === 'APPROVED') {
<div class="pg-card">
<div class="pos-section-label">Settlement</div>
<div class="inv-form-grid" style="padding: 16px">
@if (reviewRequest?.settlement_amount) {
<label class="inv-form-field">
<span>Amount Credited</span>
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
</label>
<label class="inv-form-field">
<span>Credited To (Linked Savings A/C)</span>
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
</label>
<label class="inv-form-field">
<span>Savings Transaction Ref.</span>
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
</label>
} @else {
<div class="pg-empty" style="grid-column: span 2">
Settlement is processed automatically on approval. Details aren't available when reopening an
already-approved request — check the linked savings account's transaction history to confirm.
</div>
}
</div>
</div>
}
@if (isReview && activityLog.length > 0) {
<div class="pg-card">
<div class="pos-section-label">Activity Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>User</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (entry of activityLog; track entry.timestamp) {
<tr>
<td>{{ entry.timestamp | date: 'medium' }}</td>
<td>{{ entry.action }}</td>
<td>{{ entry.user }}</td>
<td>{{ entry.note || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (showConfirmDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Confirm Share Withdrawal Request</h3>
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
<table class="pg-table">
<tbody>
<tr>
<td>Account No / Client</td>
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
</tr>
<tr>
<td>Shares to Withdraw</td>
<td>
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
</td>
</tr>
<tr>
<td>Remaining Shares After Withdrawal</td>
<td>{{ remainingShares }}</td>
</tr>
<tr>
<td>Requested Date</td>
<td>{{ requestedDate }}</td>
</tr>
<tr>
<td>Board Resolution Number</td>
<td>{{ resolutionNumber }}</td>
</tr>
<tr>
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
<table class="pg-table">
<tbody>
@for (check of verificationChecks; track check.label) {
<tr>
<td>{{ check.label }}</td>
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
</td>
</tr>
}
</tbody>
</table>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
</button>
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
</div>
</div>
</div>
}
@if (showRejectDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Reject Share Withdrawal Request</h3>
<label class="inv-form-field">
<span>* Rejection Reason</span>
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
</label>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--danger"
[disabled]="!rejectionReason.trim()"
(click)="confirmRejectReview()"
>
Confirm Reject
</button>
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
</div>
</div>
</div>
}
</div>
@@ -1,433 +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/.
*/
.swr-modal-backdrop {
position: fixed;
inset: 0;
background: rgb(13 27 42 / 55%);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.swr-modal {
background: #fff;
border-radius: 12px;
padding: 20px;
width: 420px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
h3 {
margin: 0 0 12px;
font-size: 16px;
color: #0d1b2a;
}
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
resize: vertical;
box-sizing: border-box;
}
&:has(table) {
width: 560px;
}
}
.swr-check-pass {
color: #1a7f37;
font-weight: 600;
}
.swr-check-fail {
color: #b42318;
font-weight: 600;
}
.swr-chip {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
&--ok {
background: #eafaf0;
color: #1a7f37;
}
&--warn {
background: #fdecea;
color: #b42318;
}
}
/* ---------- Verification trigger card ---------- */
.swrv-trigger {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
flex-wrap: wrap;
&__icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #3457d5, #6c8cff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
}
&__text {
flex: 1;
min-width: 200px;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
line-height: 1.4;
}
}
/* ---------- Validate pipeline modal ---------- */
@keyframes swrv-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes swrv-zoom {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes swrv-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.swrv-spin {
animation: swrv-spin 0.9s linear infinite;
}
.swrv-modal {
position: fixed;
inset: 0;
// No backdrop-filter: it would make this element a containing block for any position:fixed
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
// caused. Extra opacity compensates for the loss of the blur look.
background: rgb(15 25 45 / 65%);
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: swrv-fade 0.2s ease-out;
&__card {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
animation: swrv-zoom 0.25s ease-out;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid #eef1f5;
}
&__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__title-icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, #3457d5, #6c8cff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
&:not(:disabled):hover {
color: #b42318;
border-color: #b42318;
background: #fdecea;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__body {
padding: 20px 22px;
overflow-y: auto;
flex: 1;
}
&__intro {
font-size: 13px;
color: #475569;
line-height: 1.55;
margin: 0 0 16px;
strong {
color: #0d1b2a;
font-weight: 700;
}
}
&__warn {
display: flex;
gap: 10px;
padding: 12px 14px;
background: #fef3e2;
border: 1px solid rgb(217 119 6 / 30%);
border-left: 3px solid #d97706;
border-radius: 8px;
margin-bottom: 16px;
font-size: 12px;
color: #633806;
line-height: 1.5;
mat-icon {
color: #d97706;
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
strong {
display: block;
font-weight: 700;
color: #4d2a05;
}
}
&__result {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
padding: 12px 14px;
background: #eafaf0;
border: 1px solid rgb(26 127 55 / 25%);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #1a7f37;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
&__footer {
padding: 16px 22px;
border-top: 1px solid #eef1f5;
display: flex;
justify-content: flex-end;
gap: 10px;
background: #f8fafc;
}
}
.swrv-steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.swrv-step {
display: flex;
gap: 14px;
padding: 10px 0;
position: relative;
&:not(:last-child)::after {
content: '';
position: absolute;
left: 15px;
top: 38px;
bottom: -4px;
width: 2px;
background: #e2e8f0;
}
&__indicator {
width: 32px;
height: 32px;
border-radius: 50%;
background: #fff;
border: 2px solid #e2e8f0;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
position: relative;
z-index: 1;
transition: all 0.25s;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
&__content {
padding-top: 5px;
flex: 1;
min-width: 0;
}
&__label {
font-size: 13px;
font-weight: 600;
color: #475569;
}
&__msg {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
&--active {
.swrv-step__indicator {
background: #3457d5;
border-color: #3457d5;
color: #fff;
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
}
.swrv-step__label {
color: #3457d5;
}
}
&--done {
.swrv-step__indicator {
background: #1a7f37;
border-color: #1a7f37;
color: #fff;
}
&::after {
background: #1a7f37;
}
.swrv-step__label {
color: #0d1b2a;
}
}
&--failed {
.swrv-step__indicator {
background: #b42318;
border-color: #b42318;
color: #fff;
}
.swrv-step__label {
color: #b42318;
}
}
}
@media (prefers-reduced-motion: reduce) {
.swrv-spin {
animation: none;
}
.swrv-modal,
.swrv-modal__card {
animation: none;
}
}
@@ -1,413 +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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
export type ShareWdMode = 'create' | 'review';
interface ActivityEntry {
timestamp: string;
action: string;
user: string;
note?: string;
}
/**
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
* the member's Share Account, enters how many shares to withdraw plus the governing Board
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
* entry point a share withdrawal should go through — approval server-side executes the real
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
*/
@Component({
selector: 'mifosx-share-withdrawal-request',
templateUrl: './share-withdrawal-request.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'./share-withdrawal-request.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
* and requestId come from these inputs rather than the route, and success/close actions emit
* events instead of navigating away. */
@Input() embedded = false;
@Input() overrideMode: ShareWdMode | null = null;
@Input() overrideRequestId: number | null = null;
@Output() closeRequested = new EventEmitter<void>();
@Output() actionCompleted = new EventEmitter<void>();
mode: ShareWdMode = 'create';
requestId: number | null = null;
submitting = false;
loadingAccount = false;
selectedAccountId = '';
accountOptions: SearchSelectOption[] = [];
account: any = null;
requestedShares: number | null = null;
requestedDate = '';
resolutionNumber = '';
meetingDate = '';
description = '';
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
showConfirmDialog = false;
showValidateModal = false;
validating = false;
validateAllPassed = false;
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
[];
get remainingShares(): number {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
return held - (this.requestedShares || 0);
}
get unitPrice(): number {
return Number(this.account?.currentMarketPrice ?? 0);
}
get withdrawalValue(): number {
return (this.requestedShares || 0) * this.unitPrice;
}
get paymentTypeOptions(): any[] {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
get selectedSettlementPaymentType(): any {
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
}
get settlementPaymentTypeName(): string {
if (this.isReview) {
return this.reviewRequest?.settlement_payment_type_name || '—';
}
return this.selectedSettlementPaymentType?.name || '—';
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
if (this.requestedShares > held) return false;
if (!this.requestedDate) return false;
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
return true;
}
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
const shares = this.requestedShares || 0;
return [
{
label: 'Board Resolution Number provided',
pass: !!this.resolutionNumber?.trim(),
detail: this.resolutionNumber?.trim() || 'Missing'
},
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
{
label: 'Shares requested within shares held',
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
detail: this.settlementPaymentTypeName
}
];
}
get isReview(): boolean {
return this.mode === 'review';
}
get pageTitle(): string {
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
}
get pageSubtitle(): string {
return this.isReview
? "Approve or reject the clerk's share withdrawal request."
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
}
ngOnInit(): void {
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
this.requestId = idParam ? Number(idParam) : null;
const today = new Date();
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
this.meetingDate = this.requestedDate;
this.sharesService
.getShareAccountSummaries()
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.accountOptions = (rows || [])
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
.map((r) => ({
value: String(r.id),
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
searchText: `${r.accountNo} ${r.clientName}`
}));
});
this.cashierService
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
this.account = null;
return;
}
this.loadingAccount = true;
this.sharesService
.getSharesAccountData(accountId, false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
this.account = acc;
this.loadingAccount = false;
});
}
private loadReviewRequest(id: number): void {
this.wdService.getById(id).subscribe({
next: (req: any) => {
this.reviewRequest = req;
this.requestedShares = Number(req.requested_shares);
this.requestedDate = req.requested_date
? String(req.requested_date).split('T')[0].split(' ')[0]
: this.requestedDate;
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
if (req.activity_log) {
try {
this.activityLog = JSON.parse(req.activity_log);
} catch {
this.activityLog = [];
}
}
this.sharesService
.getSharesAccountData(String(req.share_account_id), false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => (this.account = acc));
},
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
});
}
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
openConfirmDialog(): void {
if (!this.isFormValid || this.submitting) {
return;
}
this.showConfirmDialog = true;
}
cancelConfirmDialog(): void {
this.showConfirmDialog = false;
}
confirmSubmit(): void {
this.showConfirmDialog = false;
this.submitting = true;
const payload = {
shareAccountId: Number(this.selectedAccountId),
accountNo: this.account?.accountNo,
clientId: this.account?.clientId,
clientName: this.account?.clientName,
requestedShares: this.requestedShares,
requestedDate: this.requestedDate,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
};
this.wdService.submit(payload).subscribe({
next: () => {
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/clerk/dashboard']);
}
},
error: (err) => {
this.submitting = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
duration: 6000
});
}
});
}
openValidateModal(): void {
if (!this.reviewRequest) return;
this.showValidateModal = true;
this.runBackendValidation();
}
closeValidateModal(): void {
if (this.validating) return;
this.showValidateModal = false;
}
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
* than relying on data already loaded in the browser — catches drift since submission. Results
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
runBackendValidation(): void {
if (!this.reviewRequest || this.validating) return;
this.validating = true;
this.backendChecks = null;
this.validateSteps = [];
this.wdService.validate(this.reviewRequest.id).subscribe({
next: (res: any) => {
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
this.backendChecks = checks;
this.validateAllPassed = !!res?.allPassed;
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
this.revealValidateSteps(0);
},
error: (err) => {
this.validating = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
duration: 6000
});
}
});
}
private revealValidateSteps(index: number): void {
if (index >= this.validateSteps.length) {
this.validating = false;
return;
}
this.validateSteps[index].status = 'active';
setTimeout(() => {
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
this.revealValidateSteps(index + 1);
}, 320);
}
approveReview(): void {
if (!this.reviewRequest) return;
this.wdService.approve(this.reviewRequest.id).subscribe({
next: (res: any) => {
const message =
res?.settlementAmount != null
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
this.snackBar.open(message, 'Close', { duration: 6000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
duration: 6000
})
});
}
openRejectDialog(): void {
this.showRejectDialog = true;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
next: () => {
this.showRejectDialog = false;
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
duration: 6000
})
});
}
goBack(): void {
if (this.embedded) {
this.closeRequested.emit();
return;
}
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
}
}
@@ -1,98 +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="fm-dialog">
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px;">
<mat-icon class="fm-dialog__icon">people</mat-icon>
<span style="font-weight: 500; font-size: 18px;">CHILDREN</span>
</div>
<mat-dialog-content>
<form [formGroup]="childrenForm">
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px;">
@for (child of children.controls; track $index) {
<div [formGroupName]="$index"
style="position: relative; padding: 24px 16px 16px; margin-top: 16px; margin-bottom: 24px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff;">
<div class="fm-grid">
<mat-form-field appearance="outline">
<mat-label>Child Full Name*</mat-label>
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Gender*</mat-label>
<mat-select formControlName="genderId" required>
@for (g of genderIdOptions; track g.id) {
<mat-option [value]="g.id">{{ g.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
<mat-label>Date of Birth</mat-label>
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
<mat-datepicker #childDobPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Nationality</mat-label>
<mat-select formControlName="nationality">
<mat-option value="INDIAN">Indian</mat-option>
<mat-option value="NRI">NRI</mat-option>
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Aadhaar Number (Optional)</mat-label>
<input
matInput
formControlName="aadhaarNumber"
maxlength="12"
placeholder="12-digit Aadhaar"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>PAN Number (Optional)</mat-label>
<input
matInput
formControlName="panNumber"
maxlength="10"
placeholder="ABCDE1234F"
style="text-transform: uppercase"
(input)="
$any(children.at($index)).get('panNumber')
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (
$any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched
) {
<mat-error>Invalid PAN format</mat-error>
}
</mat-form-field>
</div>
</div>
@if ($index < children.controls.length - 1) { <div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;">
</div>
}
}
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;"></div>
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
<mat-icon>add</mat-icon> Add Another Child
</button>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
Add Member
</button>
</mat-dialog-actions>
</div>
@@ -1,106 +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, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'cbs-client-family-member-child-dialog',
templateUrl: './client-family-member-child-dialog.component.html',
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatIcon,
MatButtonModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatTooltipModule
]
})
export class ClientFamilyMemberChildDialogComponent implements OnInit {
childrenForm: UntypedFormGroup;
genderIdOptions: any[];
maxDate: Date = new Date();
constructor(
public dialogRef: MatDialogRef<ClientFamilyMemberChildDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private formBuilder: UntypedFormBuilder
) {
this.genderIdOptions = data.genderIdOptions;
this.childrenForm = this.formBuilder.group({
children: this.formBuilder.array([])
});
}
ngOnInit() {
if (this.data.children && this.data.children.length > 0) {
this.data.children.forEach((child: any) => {
this.children.push(this.formBuilder.group({
id: [child.id],
fullName: [child.fullName, Validators.required],
genderId: [child.genderId, Validators.required],
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
}));
});
} else {
this.addChild();
}
}
get children(): UntypedFormArray {
return this.childrenForm.get('children') as UntypedFormArray;
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: ['', Validators.required],
genderId: ['', Validators.required],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
}
submit(): void {
if (this.childrenForm.valid) {
this.dialogRef.close(this.children.value);
}
}
}
@@ -139,21 +139,6 @@
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span> <span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
</div> </div>
} }
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
<div class="fm-readonly-remarks">
<span class="fm-readonly-label">Children</span>
<div class="fm-children-list">
@for (child of familyMemberForm.get('children')?.value; track $index) {
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px;">
<span>{{ child.fullName }}</span>
@if (child.dateOfBirth) {
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
}
</div>
}
</div>
</div>
}
} }
<!-- ═══ ADD / EDIT MODE ═══ --> <!-- ═══ ADD / EDIT MODE ═══ -->
@@ -462,19 +447,6 @@
<mat-label>Remarks</mat-label> <mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea> <textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field> </mat-form-field>
<!-- Children Section -->
@if (isSpouse) {
<div class="fm-section-label" style="margin-top: 16px;">Children</div>
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between;">
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
@if (familyMemberForm.get('hasChildren')?.value) {
<button mat-stroked-button type="button" color="primary" (click)="openChildrenDialog()" style="margin-left: 16px;">
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
</button>
}
</div>
}
} }
</form> </form>
</mat-dialog-content> </mat-dialog-content>
@@ -17,7 +17,7 @@ import {
MatDialogClose, MatDialogClose,
MatDialogContent MatDialogContent
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
@@ -58,8 +58,6 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
maxDate = new Date(); maxDate = new Date();
hasChildrenChecked = false;
familyMemberForm: UntypedFormGroup; familyMemberForm: UntypedFormGroup;
/** Client search */ /** Client search */
@@ -199,23 +197,6 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
// Re-fetch linked client details (profile, signature, KYC status) // Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString()); this.fetchLinkedClientData(m.linkedClientId.toString());
} }
if (m.children && m.children.length > 0) {
this.hasChildrenChecked = true;
this.familyMemberForm.patchValue({ hasChildren: true });
m.children.forEach((child: any) => {
this.children.push(
this.formBuilder.group({
id: [child.id],
fullName: [child.fullName, Validators.required],
genderId: [child.genderId, Validators.required],
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
})
);
});
}
if (this.isViewMode) { if (this.isViewMode) {
this.familyMemberForm.disable(); this.familyMemberForm.disable();
} }
@@ -244,83 +225,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
isAlive: [true], isAlive: [true],
marriageDate: [''], marriageDate: [''],
marriageAuthority: [''], marriageAuthority: [''],
linkedClientId: [''], linkedClientId: ['']
hasChildren: [false],
children: this.formBuilder.array([])
}); });
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe(value => {
if (value && !this.hasChildrenChecked) {
this.openChildrenDialog();
} else if (!value) {
this.hasChildrenChecked = false;
this.children.clear();
}
});
}
get children(): UntypedFormArray {
return this.familyMemberForm.get('children') as UntypedFormArray;
}
async openChildrenDialog() {
// dynamically import the dialog to avoid circular dependency issues if any
const { ClientFamilyMemberChildDialogComponent } = await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
data: {
children: this.children.value,
genderIdOptions: this.data.options.genderIdOptions
},
width: '500px'
});
dialogRef.afterClosed().subscribe((result: any[]) => {
if (result) {
this.children.clear();
result.forEach(child => {
this.children.push(this.formBuilder.group({
id: [child.id],
fullName: [child.fullName, Validators.required],
genderId: [child.genderId, Validators.required],
dateOfBirth: [child.dateOfBirth],
nationality: [child.nationality || 'INDIAN'],
aadhaarNumber: [child.aadhaarNumber || ''],
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
}));
});
this.hasChildrenChecked = true;
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
} else {
if (this.children.length === 0) {
this.hasChildrenChecked = false;
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
}
}
});
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: ['', Validators.required],
genderId: ['', Validators.required],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
if (this.children.length === 0) {
this.familyMemberForm.patchValue({ hasChildren: false });
}
} }
/** Handle marriage certificate file selection */ /** Handle marriage certificate file selection */
@@ -575,25 +481,6 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
member._linkedClient = this.selectedClient; member._linkedClient = this.selectedClient;
} }
// Format children dates
if (this.isSpouse && member.children && member.children.length > 0) {
member.children = member.children.map((child: any) => {
const c = { ...child };
if (c.dateOfBirth instanceof Date) {
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
} else if (!c.dateOfBirth) {
delete c.dateOfBirth;
}
if (c.panNumber) {
c.panNumber = c.panNumber.toUpperCase();
}
return c;
});
} else {
delete member.children;
}
delete member.hasChildren;
// Attach marriage certificate file if present // Attach marriage certificate file if present
if (this.marriageCertFile) { if (this.marriageCertFile) {
member._marriageCertFile = this.marriageCertFile; member._marriageCertFile = this.marriageCertFile;
@@ -533,6 +533,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
screeningLogId: number | null = null; screeningLogId: number | null = null;
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */ /** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = ''; private verifiedName: string = '';
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
private suppressMonthlyAnnualLink = false;
/** /**
* @param {FormBuilder} formBuilder Form Builder * @param {FormBuilder} formBuilder Form Builder
@@ -709,6 +711,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const data = this.clientData; const data = this.clientData;
this.loadExistingSeniorPwdDocuments(); this.loadExistingSeniorPwdDocuments();
// Loading an existing client's independently-saved monthly/annual values must not run
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
// instead of keeping the exact historical values as saved.
this.suppressMonthlyAnnualLink = true;
// Patch all base controls that exist on the form // Patch all base controls that exist on the form
this.createClientForm.patchValue({ this.createClientForm.patchValue({
officeId: data.officeId, officeId: data.officeId,
@@ -743,6 +751,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
? new Date(data.timeline.submittedOnDate) ? new Date(data.timeline.submittedOnDate)
: this.settingsService.businessDate : this.settingsService.businessDate
}); });
this.suppressMonthlyAnnualLink = false;
// Patch name fields (already created by buildDependencies via legalFormId subscription) // Patch name fields (already created by buildDependencies via legalFormId subscription)
const legalFormId = data.legalForm?.id || LegalFormId.PERSON; const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
@@ -957,7 +966,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome'); this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover'); this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
this.linkIncomeToTurnover();
} }
/** /**
@@ -994,37 +1002,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}); });
} }
/**
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
* and annual income for annual/yearly turnover).
*/
private linkIncomeToTurnover(): void {
const monthlyIncome = this.createClientForm.get('monthlyIncome');
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
const annualIncome = this.createClientForm.get('annualIncome');
const annualTurnover = this.createClientForm.get('annualTurnover');
if (monthlyIncome && monthlyTurnover) {
monthlyIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
if (annualIncome && annualTurnover) {
annualIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
}
/** /**
* Sets select dropdown options. * Sets select dropdown options.
*/ */
@@ -2259,26 +2236,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
} }
// Match monthly income ranges // Match monthly income ranges
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) { if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
continue; continue;
} }
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) { if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
continue; continue;
} }
// Match annual income ranges (evaluated against totalAnnualIncome) // Match annual income ranges (evaluated against totalAnnualIncome)
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) { if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
continue; continue;
} }
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) { if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
continue; continue;
} }
// Match turnover ranges // Match turnover ranges
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) { if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
continue; continue;
} }
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) { if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
continue; continue;
} }
@@ -723,33 +723,6 @@
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span> <span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
</div> </div>
} }
<!-- KYC Exception Flag -->
<div class="exception-flag-box" style="margin-top: 20px; padding: 16px; border: 1px solid #f59e0b; background: #fffbeb; border-radius: 8px; clear: both;">
<h5 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #b45309; display: flex; align-items: center; gap: 8px;">
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em;">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div> </div>
<!-- ═══ SECTION: KYC Document Management ═══ --> <!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -1437,7 +1410,6 @@
</div> </div>
</div> </div>
<!-- ═══ SECTION: Photograph & Signature ═══ --> <!-- ═══ SECTION: Photograph & Signature ═══ -->
<div class="section-block"> <div class="section-block">
<h4 class="section-title"> <h4 class="section-title">
@@ -29,7 +29,6 @@ import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component'; import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
@@ -153,8 +152,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocumentName: 'KYC Document Type', kycDocumentName: 'KYC Document Type',
kycDocumentNumber: 'KYC Document Number', kycDocumentNumber: 'KYC Document Number',
amlCheckStatus: 'AML Check Status', amlCheckStatus: 'AML Check Status',
bannedEntityMatch: 'Banned Entity List Match', bannedEntityMatch: 'Banned Entity List Match'
remarks: 'Reason for Re-edit'
}; };
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value /** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
@@ -276,14 +274,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
riskCalculationReason = ''; riskCalculationReason = '';
showRiskRulesTable = false; showRiskRulesTable = false;
/**
* Exception state as last read back from the server. Needed to tell "this client never had an
* exception" from "this client had one and has now cleared it" — only the latter should be
* stamped resolved and surface in the Resolved tab.
*/
persistedIsException = false;
persistedExceptionReason: string | null = null;
/** PAN verification */ /** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false; panVerifying = false;
@@ -433,8 +423,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumber: [ aadhaarNumber: [
'', '',
[ [
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/), Validators.pattern(/^\d{12}$/),
Validators.maxLength(14) Validators.maxLength(12)
] ]
], ],
panAadhaarSeedingStatus: [''], panAadhaarSeedingStatus: [''],
@@ -478,8 +468,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
allowRiskOverride: [false], allowRiskOverride: [false],
allowAmlOverride: [false], allowAmlOverride: [false],
remarks: [''], remarks: [''],
isException: [false],
exceptionReason: [[]],
amlCheckStatus: [ amlCheckStatus: [
'CLEAR', 'CLEAR',
Validators.required Validators.required
@@ -622,12 +610,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
dormantFlag: 'ACTIVE', dormantFlag: 'ACTIVE',
accountStatus: 'ACTIVE', accountStatus: 'ACTIVE',
form60Type: 'FORM_60', form60Type: 'FORM_60',
form15Type: '15G', form15Type: '15G'
isException: false,
exceptionReason: []
}); });
this.persistedIsException = false;
this.persistedExceptionReason = null;
this.kycId = ''; this.kycId = '';
this.kycLoaded = false; this.kycLoaded = false;
this.kycStatus = 0; this.kycStatus = 0;
@@ -958,18 +942,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form60Date: parseDate(data.form60Date), form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false, form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G', form15Type: data.form15Type || '15G',
form15FY: data.form15FY || '', form15FY: data.form15FY || ''
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
}); });
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name // Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) { if (data.kycDocumentName) {
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null; this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
@@ -1869,21 +1843,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15Applicable: v.form15Applicable || false, form15Applicable: v.form15Applicable || false,
form15Type: v.form15Type || null, form15Type: v.form15Type || null,
form15FY: v.form15FY || null, form15FY: v.form15FY || null,
remarks: v.remarks || null, remarks: v.remarks || null
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
...deriveKycExceptionFields({
panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED',
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
// company with a permanent exception.
aadhaarApplicable: !this.isNonIndividual,
hadException: this.persistedIsException,
previousReason: this.persistedExceptionReason,
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
})
}; };
} }
@@ -1896,8 +1856,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.enterprisePan || v.enterprisePan ||
v.cinNumber || v.cinNumber ||
v.kycDocumentName || v.kycDocumentName ||
v.form15Applicable || v.form15Applicable
v.isException
); );
} }
@@ -60,7 +60,6 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component'; import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component'; import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component'; import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component'; import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard'; import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -194,11 +193,6 @@ const routes: Routes = [
component: Form60TabComponent, component: Form60TabComponent,
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false } data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
}, },
{
path: 'form121',
component: Form121TabComponent,
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{ {
path: 'documents', path: 'documents',
component: DocumentsTabComponent, component: DocumentsTabComponent,
@@ -545,15 +545,6 @@
> >
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }} <i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
</a> </a>
<a
mat-tab-link
[routerLink]="['./form121']"
routerLinkActive
#form121="routerLinkActive"
[active]="form121.isActive"
>
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
</a>
<a <a
mat-tab-link mat-tab-link
[routerLink]="['./documents']" [routerLink]="['./documents']"
@@ -36,34 +36,7 @@
<strong>Status:</strong> <strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span> <span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p> </p>
</div>
<!-- Action buttons: only shown for DISPATCHED / IN_TRANSIT, only for users with UPDATE_WELCOMEKIT permission -->
<ng-container *mifosxHasPermission="'UPDATE_WELCOMEKIT'">
<div
*ngIf="canUpdateStatus(dispatch.status)"
style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"
>
<button
mat-stroked-button
color="primary"
type="button"
[disabled]="isLoading"
(click)="updateStatus(dispatch, 'DELIVERED')"
>
✅ Mark as Delivered
</button>
<button
mat-stroked-button
color="warn"
type="button"
[disabled]="isLoading"
(click)="updateStatus(dispatch, 'RETURNED')"
>
↩ Mark as Returned
</button>
</div>
</ng-container>
</div><!-- end dispatch-tracking-panel -->
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" /> <hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
@@ -165,30 +165,6 @@ export class WelcomeKitDialogComponent implements OnInit {
}); });
} }
canUpdateStatus(status: string): boolean {
return status === 'DISPATCHED' || status === 'IN_TRANSIT';
}
updateStatus(dispatch: any, newStatus: string) {
const label = newStatus === 'DELIVERED' ? 'Delivered' : 'Returned';
const confirmed = confirm(`Are you sure you want to mark this as ${label}? This action cannot be undone from the UI.`);
if (!confirmed) {
return;
}
this.isLoading = true;
this.clientsService.updateWelcomeKitStatus(dispatch.id, newStatus).subscribe({
next: () => {
this.snackBar.open(`Welcome Kit marked as ${label} successfully`, 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
this.isLoading = false;
this.snackBar.open(`Failed to mark Welcome Kit as ${label}`, 'Close', { duration: 3000 });
}
});
}
getDisplayAddress(dispatch: any): string { getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || ''; const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || ''; const name = this.welcomeKitsData?.clientName || '';
@@ -1,428 +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="f121">
@if (loading) {
<div class="f121__loading">
<mat-spinner [diameter]="36"></mat-spinner>
<span>Reading the customer's deposit ledger…</span>
</div>
} @else if (loadError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
</div>
} @else {
<!-- ── Header ── -->
<div class="f121__head">
<div>
<h3 class="f121__title">Form 121 &amp; TDS on deposit interest</h3>
<div class="f121__sub">
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
</div>
</div>
<div class="f121__controls">
@if (availableFys.length) {
<label class="f121__field">
<span>Financial year</span>
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
@for (year of availableFys; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
}
<div class="f121__field">
<span>Settlement</span>
<div
class="f121__mode"
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
>
<mat-icon>schedule</mat-icon>
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
</div>
</div>
</div>
</div>
@if (!schedule) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>
@if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
} @else {
No interest has been credited to this customer's deposits yet.
}
</div>
</div>
} @else {
<!-- ── KPI strip ── -->
<div class="f121__kpis">
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
</div>
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
<div class="f121__kpi-l">TDS deducted</div>
</div>
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
</div>
@if (thresholdCrossed) {
<div class="f121__kpi f121__kpi--warn">
<div class="f121__kpi-n">Crossed</div>
<div class="f121__kpi-l">TDS applies this year</div>
</div>
} @else {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
<div class="f121__kpi-l">More interest before TDS starts</div>
</div>
}
@if (tdsSaved > 0) {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Saved by Form 121</div>
</div>
}
</div>
<!-- ── Alerts ── -->
@if (variance !== 0) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
</div>
</div>
}
@if (declarationNotAppliedToAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
</div>
</div>
}
@if (declarationUselessOnJointAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
This customer has filed Form 121, but tax on
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
against the first-named holder. Their declaration does not cover those — the primary holder must file their
own.
</div>
</div>
}
@if (shouldSuggestDeclaration) {
<div class="f121__note f121__note--tip">
<mat-icon>lightbulb_outline</mat-icon>
<div>
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
reclaiming it later as a refund.
</div>
</div>
}
@if (!panWellFormed) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
</div>
</div>
}
@for (note of schedule.notes; track note) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>{{ note }}</div>
</div>
}
<!-- ── Declaration panel ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>assignment_turned_in</mat-icon>
<span>Declaration</span>
</div>
<div class="f121__kv">
<div class="f121__kv-row">
<span>Status</span>
<span>
@if (declarationOnRecord) {
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
} @else {
<span class="f121__pill">Not filed</span>
}
</span>
</div>
<div class="f121__kv-row">
<span>Form &amp; threshold band</span>
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
</div>
<div class="f121__kv-row">
<span>PAN</span>
<span class="f121__mono">
{{ pan || '—' }}
@if (pan && !panWellFormed) {
<span class="f121__pill f121__pill--warn">invalid</span>
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
</span>
</div>
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
<div class="f121__kv-row f121__kv-row--warn">
<span>Eligibility</span>
<span>{{ reason }}</span>
</div>
}
@for (note of eligibility.unassessed; track note) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Not assessed</span>
<span>{{ note }}</span>
</div>
}
}
</div>
</div>
<!-- ── Quarter-wise ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>table_chart</mat-icon>
<span>Quarter by quarter</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of schedule.lines; track trackLine($index, line)) {
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
<td>
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="f121__pill f121__pill--warn">threshold crossed</span>
}
@if (line.declarationActive) {
<span class="f121__pill f121__pill--ok">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="muted">{{ line.dueDate }}</td>
</tr>
}
<tr class="f121__row--total">
<td><strong>Total</strong></td>
<td class="num">
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
</td>
<td></td>
<td></td>
<td class="num">
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
@if (schedule.shieldedInterest > 0) {
<div class="f121__foot">
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
</div>
}
</div>
<!-- ── Section 1: accounts that count ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>account_balance</mat-icon>
<span>Deposits counted towards this customer's TDS</span>
</div>
@if (liableAccounts.length) {
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th class="num">TDS deducted</th>
<th>Withholding</th>
<th>Settles</th>
</tr>
</thead>
<tbody>
@for (a of liableAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
<td>
@if (a.withHoldTax) {
<span class="f121__pill f121__pill--warn">On</span>
@if (a.taxGroup) {
<div class="muted">{{ a.taxGroup }}</div>
}
} @else {
<span class="f121__pill">Off</span>
}
</td>
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
whole, not branch by branch.
</div>
} @else {
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
}
</div>
<!-- ── Section 2: joint holdings ── -->
@if (jointHolderAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>group</mat-icon>
<span>Deposits where this customer is a joint holder</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>Deducted against</th>
</tr>
</thead>
<tbody>
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="muted">
First-named holder
@if (a.deducteeClientId) {
(client {{ a.deducteeClientId }})
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
</div>
</div>
}
<!-- ── Section 3: exempt accounts ── -->
@if (exemptAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>savings</mat-icon>
<span>Savings and current accounts</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>TDS</th>
</tr>
</thead>
<tbody>
@for (a of exemptAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td><span class="f121__pill">Not applicable</span></td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
</div>
</div>
}
}
}
</div>
@@ -1,334 +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/.
*/
.f121 {
--f121-line: #e5e7eb;
--f121-bg: #f9fafb;
--f121-ink: #111827;
--f121-muted: #6b7280;
--f121-blue: #1d4ed8;
--f121-green: #15803d;
--f121-amber: #b45309;
padding: 16px;
font-size: 13px;
color: var(--f121-ink);
}
.f121__loading {
display: flex;
align-items: center;
gap: 12px;
padding: 40px 16px;
color: var(--f121-muted);
}
/* ── Header ── */
.f121__head {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
}
.f121__title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.f121__sub {
margin-top: 4px;
color: var(--f121-muted);
font-size: 12px;
}
.f121__controls {
display: flex;
gap: 16px;
align-items: flex-end;
}
.f121__field {
display: flex;
flex-direction: column;
gap: 4px;
> span {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f121-muted);
}
select {
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: #fff;
font-size: 13px;
color: inherit;
}
}
.f121__mode {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: var(--f121-bg);
font-weight: 600;
cursor: help;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--f121-muted);
}
}
/* ── KPI strip ── */
.f121__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.f121__kpi {
padding: 12px 14px;
border: 1px solid var(--f121-line);
border-left: 3px solid var(--f121-blue);
border-radius: 8px;
background: #fff;
&--ok {
border-left-color: var(--f121-green);
}
&--warn {
border-left-color: var(--f121-amber);
}
}
.f121__kpi-n {
font-size: 19px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.f121__kpi-l {
margin-top: 2px;
font-size: 11px;
color: var(--f121-muted);
line-height: 1.35;
}
/* ── Notes ── */
.f121__note {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
padding: 11px 13px;
border-radius: 8px;
border-left: 3px solid var(--f121-blue);
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
line-height: 1.5;
mat-icon {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-blue);
}
&--warn {
border-left-color: var(--f121-amber);
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
mat-icon {
color: var(--f121-amber);
}
}
&--tip {
border-left-color: var(--f121-green);
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
mat-icon {
color: var(--f121-green);
}
}
}
/* ── Cards ── */
.f121__card {
margin-bottom: 16px;
border: 1px solid var(--f121-line);
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.f121__card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
background: var(--f121-bg);
border-bottom: 1px solid var(--f121-line);
font-weight: 700;
font-size: 12.5px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-muted);
}
}
.f121__foot {
padding: 10px 14px;
border-top: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 11.5px;
line-height: 1.5;
}
.f121__empty {
padding: 20px 14px;
color: var(--f121-muted);
text-align: center;
}
/* ── Key/value list ── */
.f121__kv-row {
display: grid;
grid-template-columns: 190px 1fr;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
&:last-child {
border-bottom: 0;
}
> span:first-child {
color: var(--f121-muted);
font-weight: 600;
}
&--muted > span:last-child {
color: var(--f121-muted);
font-size: 12px;
}
&--warn > span:last-child {
color: var(--f121-amber);
}
}
/* ── Tables ── */
.f121__table-wrap {
overflow-x: auto;
}
.f121__table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid var(--f121-line);
color: var(--f121-muted);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
td {
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.muted {
color: var(--f121-muted);
font-size: 11.5px;
}
}
.f121__row--warn > td {
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
}
.f121__row--total > td {
background: var(--f121-bg);
border-top: 1px solid var(--f121-line);
}
/* ── Bits ── */
.f121__pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
& + & {
margin-left: 5px;
}
&--ok {
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
color: var(--f121-green);
}
&--warn {
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
color: var(--f121-amber);
}
}
.f121__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
@@ -1,500 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { Observable, Subject, forkJoin, of } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Domain logic */
import {
Declaration,
DeclarationForm,
EligibilityResult,
FY_TAX_CONFIG,
InterestCredit,
TdsAccountInput,
TdsMode,
TdsQuarterLine,
TdsSchedule,
ageInFy,
buildSchedule,
checkEligibility,
collectTdsCredits,
declarationFormFor,
formKindFor,
fyOf,
isTdsApplicable,
isWellFormedPan,
normaliseFy,
tdsDeducteeOf,
tdsModeFor
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
/** One of the customer's interest-bearing accounts, with its FY figures. */
export interface Form121AccountRow {
accountNo: string;
productName: string;
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
kind: string;
/** Interest credited to this account inside the selected FY. */
interest: number;
/** Tax Fineract has actually withheld on this account inside the FY. */
tdsDeducted: number;
/** Whether Fineract is currently withholding tax on this account. */
withHoldTax: boolean;
taxGroup: string | null;
/** When tax on this account settles — from the product, not a preference. */
mode: TdsMode;
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
tdsApplicable: boolean;
/** This customer is the deductee — false when they are a joint holder only. */
isDeductee: boolean;
/** Who the tax is deducted against, when it is not this customer. */
deducteeClientId: number | null;
}
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
function toIso(value: unknown): string | null {
if (Array.isArray(value) && value.length >= 3) {
const [
y,
m,
d
] = value as number[];
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
}
if (typeof value === 'string' && value.trim()) {
const parsed = new Date(value);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 10);
}
}
return null;
}
@Component({
selector: 'mifosx-form121-tab',
templateUrl: './form121-tab.component.html',
styleUrls: ['./form121-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
MatIcon,
MatProgressSpinner,
MatTooltip
]
})
export class Form121TabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private destroy$ = new Subject<void>();
clientId = '';
loading = true;
loadError = false;
// ── Customer ──
clientName = '';
dateOfBirth = '';
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
eligibility: EligibilityResult | null = null;
// ── Selection ──
availableFys: string[] = [];
fy = '';
// ── Computed ──
accounts: Form121AccountRow[] = [];
schedule: TdsSchedule | null = null;
actualTds = 0;
tdsSaved = 0;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
this.clientId = params['clientId'];
this.load();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ────────────────────────────── Loading ──────────────────────────────
private load(): void {
this.loading = true;
this.loadError = false;
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
this.applyClient(client);
this.applyKyc(kyc);
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
catchError(() => {
this.loadError = true;
return of([] as any[]);
})
)
.subscribe((details: any[]) => {
this.loading = false;
this.ingest(details.filter(Boolean));
this.recompute();
});
}
private applyClient(client: any): void {
this.clientName = client?.displayName ?? '';
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
}
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
private depositAccountsOf(accounts: any): any[] {
return (accounts?.savingsAccounts ?? []).filter(
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
);
}
private fetchAccount(a: any): Observable<any> {
const id = `${a.id}`;
const code = a.depositType?.code ?? '';
let call: Observable<any>;
if (code.includes('fixedDeposit')) {
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
} else if (code.includes('recurringDeposit')) {
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
} else {
call = this.savingsService.getSavingsAccountData(id);
}
// One unreadable account must not blank the whole tab.
return call.pipe(catchError(() => of(null)));
}
/**
* Turns each account payload into an engine input.
*
* Joint and minor attributes are read defensively: they are captured on the
* account stepper but may not surface on every read endpoint, and an absent
* field must default to a single-name account rather than throw.
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
for (const d of details) {
const code: string = d.depositType?.code ?? '';
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
const ref = {
id: d.id,
accountNo: d.accountNo ?? `${d.id}`,
depositTypeCode: code,
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
clientId: d.clientId ?? clientIdNum,
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
interestPostingPeriodType: d.interestPostingPeriodType ?? null
};
const credits: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
if (t.reversed || t.isReversal) {
continue;
}
const iso = toIso(t.date);
if (!iso) {
continue;
}
fys.add(fyOf(iso));
if (t.transactionType?.interestPosting) {
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
}
}
const deductee = tdsDeducteeOf(ref);
const meta: Form121AccountRow = {
accountNo: ref.accountNo,
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
kind: code.includes('fixedDeposit')
? 'Fixed Deposit'
: code.includes('recurringDeposit')
? 'Recurring Deposit'
: 'Savings',
interest: 0,
tdsDeducted,
withHoldTax: !!d.withHoldTax,
taxGroup: d.taxGroup?.name ?? null,
mode: tdsModeFor(ref),
tdsApplicable: isTdsApplicable(ref),
isDeductee: deductee === clientIdNum,
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
}
}
// ───────────────────────────── Computation ─────────────────────────────
/** Re-runs the engine for the selected year. */
recompute(): void {
this.schedule = null;
this.eligibility = null;
this.tdsSaved = 0;
this.accounts = this.accountInputs.map((a) => a.meta);
if (!this.fy) {
return;
}
const year = normaliseFy(this.fy);
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth) {
return;
}
// The one place rules 8 to 12 are applied: savings interest dropped, joint
// deposits attributed to the primary holder, every branch aggregated.
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
const declaration = this.declarationForSelectedFy();
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
if (declaration) {
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
}
// The branch cannot compute income earned outside the bank, so the income
// limb is reported as unassessed rather than silently passed.
this.eligibility = checkEligibility(ctx, {
estimatedInterest: this.schedule.totalInterest,
estimatedIncome: 0,
incomeOnRecord: false
});
}
/**
* When tax settles for this customer overall.
*
* Timing is per account, but the customer needs one view. A quarterly account
* anywhere in the book means a deduction falls due at the first quarter end, so
* that is the honest headline; the per-account list carries the detail.
*/
private get overallMode(): TdsMode {
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
}
// ───────────────────────── Template helpers ─────────────────────────
get mode(): TdsMode {
return this.schedule?.mode ?? 'QUARTERLY';
}
get age(): number {
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
}
get formKind(): string {
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
}
/** The form the payee would file for the selected year. */
get formLabel(): string {
if (!this.fy) {
return '—';
}
const form = declarationFormFor(this.fy, this.dateOfBirth);
return form === '121' ? 'Form 121' : `Form ${form}`;
}
/** Which deduction trigger applies, now that the forms no longer split by age. */
get bandLabel(): string {
return this.schedule?.senior ? 'Senior citizen' : 'General';
}
/**
* Provision and return references for the selected year. Both changed on
* 01 April 2026, so the template reads them rather than hard-coding them.
*/
get statute(): { section: string; statement: string; rule: string; frequency: string } {
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
rule: cfg?.declarationRule ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
/** Accounts whose interest counts towards this customer's deduction aggregate. */
get liableAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
}
/** Deposits where this customer is a joint holder but not the deductee. */
get jointHolderAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
}
/** Savings and current accounts — interest outside the charge on time deposits. */
get exemptAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => !a.tdsApplicable);
}
/** Engine figure less what the ledger shows was actually withheld. */
get variance(): number {
return (this.schedule?.totalTds ?? 0) - this.actualTds;
}
/** How much more interest before TDS starts. */
get headroom(): number {
if (!this.schedule) {
return 0;
}
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
}
get thresholdCrossed(): boolean {
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
}
/**
* The customer filed a declaration but holds joint deposits where someone else
* is the deductee — their form does nothing for those.
*/
get declarationUselessOnJointAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
}
/** Worth prompting the branch to collect a declaration. */
get shouldSuggestDeclaration(): boolean {
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
}
trackLine(_: number, line: TdsQuarterLine): number {
return line.quarter;
}
trackAccount(_: number, account: Form121AccountRow): string {
return account.accountNo;
}
}
@@ -42,19 +42,6 @@
<!-- KYC Summary (read-only when not in edit mode) --> <!-- KYC Summary (read-only when not in edit mode) -->
@if (!isEditMode && kycData) { @if (!isEditMode && kycData) {
@if (kycData.isException && !kycData.exceptionResolved) {
<div class="kyc-exception-banner" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: flex-start; gap: 12px;">
<mat-icon style="color: #d97706; margin-top: 2px;">warning</mat-icon>
<div>
<strong style="color: #92400e; font-size: 14px;">KYC Exception Active</strong>
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0;">
Account is open under a temporary KYC Exception. Reason: <strong>{{ kycData.exceptionReason || 'Other' }}</strong>.
Please contact the customer and complete verification as soon as possible.
</p>
</div>
</div>
}
<!-- Verification Table --> <!-- Verification Table -->
<div class="kv-table"> <div class="kv-table">
<div class="kv-table__header"> <div class="kv-table__header">
@@ -753,33 +740,11 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<!-- KYC Exception Flag -->
<div class="full-span exception-controls-row" style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap;">
<mat-checkbox formControlName="isException" style="margin-top: 8px;">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px;">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<!-- Remarks --> <!-- Remarks -->
<mat-form-field appearance="outline" class="full-span"> <mat-form-field appearance="outline" class="full-span">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<textarea matInput formControlName="remarks" rows="3"></textarea> <textarea matInput formControlName="remarks" rows="3"></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
@if (errorMessage) { @if (errorMessage) {
@@ -14,7 +14,6 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */ /** Custom Services */
import { ClientsService } from '../../clients.service'; import { ClientsService } from '../../clients.service';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
@@ -122,9 +121,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
'', '',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)] [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
], ],
remarks: [''], remarks: ['']
isException: [false],
exceptionReason: [[]]
}); });
ngOnInit(): void { ngOnInit(): void {
@@ -160,9 +157,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
// the full 12-digit number to re-verify, so don't pre-fill. // the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '', aadhaarNumber: '',
gstinNumber: data.gstinNumber || '', gstinNumber: data.gstinNumber || '',
remarks: data.remarks || '', remarks: data.remarks || ''
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
}); });
} }
}); });
@@ -200,28 +195,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
} }
} }
/**
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
*
* A field counts as verified if it was verified in this session OR is already verified on the
* stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it.
*/
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
return deriveKycExceptionFields({
panVerified,
aadhaarVerified,
aadhaarApplicable: !this.isNonIndividual,
hadException: this.kycData?.isException === true,
previousReason: this.kycData?.exceptionReason || null,
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
});
}
saveKyc(): void { saveKyc(): void {
if (this.kycForm.invalid) { if (this.kycForm.invalid) {
return; return;
@@ -237,10 +210,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
panNumber: this.kycForm.value.panNumber || null, panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked, aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null, gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null, remarks: this.kycForm.value.remarks || null
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
}; };
if (!this.kycId) { if (!this.kycId) {
@@ -274,37 +244,6 @@ export class KycTabComponent implements OnInit, OnDestroy {
}); });
} }
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
if (this.isEditMode) {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
next: () => {
this.loadKycData();
callback();
},
error: (err: any) => {
this.verifying[field] = false;
this.verificationStatus[field] = 'FAILED';
this.verificationError[field] = err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
}
});
} else {
callback();
}
}
verifyPAN(): void { verifyPAN(): void {
if (!this.kycId) { if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.'; this.verificationError.pan = 'KYC record not found. Save KYC details first.';
@@ -315,33 +254,31 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.pan = null; this.verificationError.pan = null;
this.verificationDetails.pan = null; this.verificationDetails.pan = null;
this.saveKycBeforeVerify(() => { this.clientsService
this.clientsService .verifyPAN(this.clientId, this.kycId)
.verifyPAN(this.clientId, this.kycId) .pipe(takeUntil(this.destroy$))
.pipe(takeUntil(this.destroy$)) .subscribe({
.subscribe({ next: (response: any) => {
next: (response: any) => { this.verifying.pan = false;
this.verifying.pan = false; if (response?.changes?.panVerified) {
if (response?.changes?.panVerified) { this.verificationStatus.pan = 'VERIFIED';
this.verificationStatus.pan = 'VERIFIED'; this.verificationDetails.pan = response.changes;
this.verificationDetails.pan = response.changes; this.loadKycData();
this.loadKycData(); } else {
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED'; this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = this.verificationError.pan = 'PAN verification failed.';
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
} }
}); },
}, 'pan'); error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
});
} }
/** Show Aadhaar input in summary view for re-verification */ /** Show Aadhaar input in summary view for re-verification */
@@ -368,33 +305,31 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.aadhaarOtpSent = false; this.aadhaarOtpSent = false;
this.aadhaarOtp = ''; this.aadhaarOtp = '';
this.saveKycBeforeVerify(() => { this.clientsService
this.clientsService .sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar) .pipe(takeUntil(this.destroy$))
.pipe(takeUntil(this.destroy$)) .subscribe({
.subscribe({ next: (response: any) => {
next: (response: any) => { this.verifying.aadhaar = false;
this.verifying.aadhaar = false; const refNumber =
const refNumber = response?.changes?.aadhaarRefNumber ||
response?.changes?.aadhaarRefNumber || response?.aadhaarRefNumber ||
response?.aadhaarRefNumber || response?.changes?.referenceNumber ||
response?.changes?.referenceNumber || response?.resourceId;
response?.resourceId; this.aadhaarOtpSent = true;
this.aadhaarOtpSent = true; this.aadhaarOtpReferenceId = refNumber || null;
this.aadhaarOtpReferenceId = refNumber || null; this.verificationStatus.aadhaar = 'PENDING';
this.verificationStatus.aadhaar = 'PENDING'; },
}, error: (err: any) => {
error: (err: any) => { this.verifying.aadhaar = false;
this.verifying.aadhaar = false; this.verificationStatus.aadhaar = 'FAILED';
this.verificationStatus.aadhaar = 'FAILED'; this.verificationError.aadhaar =
this.verificationError.aadhaar = err?.error?.developerMessage ||
err?.error?.developerMessage || err?.error?.defaultUserMessage ||
err?.error?.defaultUserMessage || err?.message ||
err?.message || 'Failed to send OTP. Please try again.';
'Failed to send OTP. Please try again.'; }
} });
});
}, 'aadhaar');
} }
/** Step 2: Verify OTP entered by user */ /** Step 2: Verify OTP entered by user */
@@ -445,33 +380,31 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.gstin = null; this.verificationError.gstin = null;
this.verificationDetails.gstin = null; this.verificationDetails.gstin = null;
this.saveKycBeforeVerify(() => { this.clientsService
this.clientsService .verifyGSTIN(this.clientId, this.kycId)
.verifyGSTIN(this.clientId, this.kycId) .pipe(takeUntil(this.destroy$))
.pipe(takeUntil(this.destroy$)) .subscribe({
.subscribe({ next: (response: any) => {
next: (response: any) => { this.verifying.gstin = false;
this.verifying.gstin = false; if (response?.changes?.gstinVerified) {
if (response?.changes?.gstinVerified) { this.verificationStatus.gstin = 'VERIFIED';
this.verificationStatus.gstin = 'VERIFIED'; this.verificationDetails.gstin = response.changes;
this.verificationDetails.gstin = response.changes; this.loadKycData();
this.loadKycData(); } else {
} else {
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED'; this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = this.verificationError.gstin = 'GSTIN verification failed.';
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
} }
}); },
}, 'gstin'); error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
});
} }
formatDate(date: any): string { formatDate(date: any): string {
-2
View File
@@ -62,7 +62,6 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component'; import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component'; import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component'; import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component'; import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component'; import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
@@ -128,7 +127,6 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
ClientPreviewStepComponent, ClientPreviewStepComponent,
ClientAddressStepComponent, ClientAddressStepComponent,
ClientFamilyMemberDialogComponent, ClientFamilyMemberDialogComponent,
ClientFamilyMemberChildDialogComponent,
CaptureImageDialogComponent, CaptureImageDialogComponent,
AddClientCollateralComponent, AddClientCollateralComponent,
ClientDatatableStepComponent, ClientDatatableStepComponent,
-43
View File
@@ -123,20 +123,6 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' }); return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
} }
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
* Only populated after the Checker has approved and the send succeeded. */
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
return this.http.get<{
emailSubject: string | null;
emailBody: string | null;
emailSent: boolean;
emailSentAt: string | null;
emailOpenedAt: string | null;
emailFrom: string | null;
emailTo: string | null;
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
}
getClientDataAndTemplate(clientId: string) { getClientDataAndTemplate(clientId: string) {
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true'); const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
return this.http.get(`/clients/${clientId}`, { params: httpParams }); return this.http.get(`/clients/${clientId}`, { params: httpParams });
@@ -972,33 +958,4 @@ export class ClientsService {
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> { dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/welcomekit`, data); return this.http.post(`/clients/${clientId}/welcomekit`, data);
} }
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
}
getKycExceptions(resolved = false): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
}
/**
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
* WHATSAPP channels — actually transmits the notice.
*
* The destination is resolved server-side from the client record; it is deliberately not part of
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
*
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
* response rather than assuming success, since the log row is written either way.
*/
saveKycContactLog(
clientId: string | number,
data: { channel: string; notes: string; officerName?: string }
): Observable<any> {
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
}
getKycContactLog(clientId: string | number): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
}
} }
@@ -430,22 +430,8 @@ export class CreateClientComponent {
} }
this.validationError = ''; this.validationError = '';
// Save KYC // Save KYC
if (this.clientKycStep?.hasKycData) { if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
if (!this.clientKycStep.kycId) { this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: (res: any) => {
if (this.clientKycStep) {
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
}
},
error: () => {}
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
.subscribe({ error: () => {} });
}
} }
// Upload photo & signature // Upload photo & signature
if (this.clientKycStep?.photographFile) { if (this.clientKycStep?.photographFile) {
@@ -710,20 +696,12 @@ export class CreateClientComponent {
// Persist the KYC document picker selection + uploaded files as a client identifier // Persist the KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(clientId); this.saveKycDocProof(clientId);
// Save/update KYC to ensure latest exception fields are persisted // Save KYC only if it wasn't already created during the KYC step
if (this.clientKycStep?.hasKycData) { if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
if (!this.clientKycStep.kycId) { this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ next: () => navigateToClient(),
next: () => navigateToClient(), error: () => navigateToClient()
error: () => navigateToClient() });
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
}
} else { } else {
navigateToClient(); navigateToClient();
} }
@@ -84,70 +84,14 @@
<mat-icon>phone_iphone</mat-icon> <mat-icon>phone_iphone</mat-icon>
<span>Mobile Number</span> <span>Mobile Number</span>
</div> </div>
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
<mat-checkbox
[checked]="oldMobileNotAccessible"
(change)="onOldMobileNotAccessibleToggle($event.checked)"
color="primary"
>
Old mobile number not accessible (lost / deactivated SIM)
</mat-checkbox>
</div>
<div class="contact-verify-panel__grid"> <div class="contact-verify-panel__grid">
@if (!oldMobileNotAccessible) { <mifosx-client-contact-otp-block
<mifosx-client-contact-otp-block [clientId]="clientId"
[clientId]="clientId" channel="MOBILE"
channel="MOBILE" [destination]="contactChanges.oldMobile"
[destination]="contactChanges.oldMobile" label="Old Mobile Number"
label="Old Mobile Number" (stateChange)="onOldMobileOtpState($event)"
(stateChange)="onOldMobileOtpState($event)" ></mifosx-client-contact-otp-block>
></mifosx-client-contact-otp-block>
} @else {
<div
class="fir-upload-box"
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
>
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
Upload Police Complaint / FIR Copy for Lost Mobile
</div>
<div style="display: flex; align-items: center; gap: 12px">
<input
type="file"
(change)="onFirFileSelected($event)"
accept=".pdf,.jpg,.jpeg,.png"
#firFileInput
hidden
/>
<button
mat-stroked-button
type="button"
(click)="firFileInput.click()"
[disabled]="uploadingFir"
>
<mat-icon>upload_file</mat-icon>
{{ firFileName ? firFileName : 'Select FIR Document' }}
</button>
@if (uploadingFir) {
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
}
@if (firDocumentId && !uploadingFir) {
<span
style="
color: #16a34a;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
"
>
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
Uploaded Successfully
</span>
}
</div>
</div>
}
<mifosx-client-contact-otp-block <mifosx-client-contact-otp-block
[clientId]="clientId" [clientId]="clientId"
channel="MOBILE" channel="MOBILE"
@@ -33,7 +33,6 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDivider } from '@angular/material/divider'; import { MatDivider } from '@angular/material/divider';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatProgressSpinner } from '@angular/material/progress-spinner';
@@ -52,7 +51,6 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTabsModule, MatTabsModule,
MatIconModule, MatIconModule,
MatCheckboxModule,
MatDivider, MatDivider,
FaIconComponent, FaIconComponent,
MatProgressSpinner, MatProgressSpinner,
@@ -110,13 +108,6 @@ export class EditClientComponent {
newMobileOtpId: number | null = null; newMobileOtpId: number | null = null;
oldMobileVerified = false; oldMobileVerified = false;
newMobileVerified = false; newMobileVerified = false;
// FIR exception flow properties for inaccessible old mobile number
oldMobileNotAccessible = false;
firFileName = '';
firDocumentId: number | null = null;
uploadingFir = false;
oldEmailOtpId: number | null = null; oldEmailOtpId: number | null = null;
newEmailOtpId: number | null = null; newEmailOtpId: number | null = null;
oldEmailVerified = false; oldEmailVerified = false;
@@ -224,14 +215,11 @@ export class EditClientComponent {
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate); return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
} }
/** Whether every OTP block required by the fields that actually changed has been verified (or FIR uploaded for lost mobile). */ /** Whether every OTP block required by the fields that actually changed has been verified. */
get contactVerificationComplete(): boolean { get contactVerificationComplete(): boolean {
const changes = this.contactChanges; const changes = this.contactChanges;
if (changes.mobileChanged) { if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified; return false;
if (!isOldSatisfied || !this.newMobileVerified) {
return false;
}
} }
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) { if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
return false; return false;
@@ -239,41 +227,6 @@ export class EditClientComponent {
return true; return true;
} }
onOldMobileNotAccessibleToggle(checked?: boolean): void {
if (checked !== undefined) {
this.oldMobileNotAccessible = checked;
}
if (this.oldMobileNotAccessible) {
this.oldMobileOtpId = null;
this.oldMobileVerified = false;
}
}
onFirFileSelected(event: any): void {
const file: File = event.target?.files?.[0];
if (!file) return;
this.firFileName = file.name;
this.uploadingFir = true;
const formData = new FormData();
formData.append('file', file);
formData.append('name', 'FIR - Lost Mobile Number');
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
next: (res: any) => {
this.uploadingFir = false;
this.firDocumentId = res?.resourceId || res?.id || null;
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
},
error: () => {
this.uploadingFir = false;
this.firDocumentId = null;
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
}
});
}
onOldMobileOtpState(state: ContactOtpState): void { onOldMobileOtpState(state: ContactOtpState): void {
this.oldMobileOtpId = state.otpId; this.oldMobileOtpId = state.otpId;
this.oldMobileVerified = state.verified; this.oldMobileVerified = state.verified;
@@ -301,10 +254,6 @@ export class EditClientComponent {
this.newMobileOtpId = null; this.newMobileOtpId = null;
this.oldMobileVerified = false; this.oldMobileVerified = false;
this.newMobileVerified = false; this.newMobileVerified = false;
this.oldMobileNotAccessible = false;
this.firFileName = '';
this.firDocumentId = null;
this.uploadingFir = false;
this.oldEmailOtpId = null; this.oldEmailOtpId = null;
this.newEmailOtpId = null; this.newEmailOtpId = null;
this.oldEmailVerified = false; this.oldEmailVerified = false;
@@ -490,11 +439,7 @@ export class EditClientComponent {
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP // email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
// state fields for the full naming rationale. // state fields for the full naming rationale.
if (changedFields.mobileChanged) { if (changedFields.mobileChanged) {
if (this.oldMobileNotAccessible && this.firDocumentId) { clientData.mobileOldOtpId = this.oldMobileOtpId;
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
} else {
clientData.mobileOldOtpId = this.oldMobileOtpId;
}
clientData.mobileNewOtpId = this.newMobileOtpId; clientData.mobileNewOtpId = this.newMobileOtpId;
} }
if (changedFields.emailChanged) { if (changedFields.emailChanged) {
@@ -685,34 +630,25 @@ export class EditClientComponent {
private detectSensitiveFieldChanges(originalData: any) { private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm; const form = this.clientGeneralForm;
if (!form || !originalData) { // Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
return { // a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
nameChanged: false, // first couple of characters, so a whitespace-only difference is otherwise invisible to the
mobileChanged: false, // user and just looks like the panel appeared for no reason. These trimmed-but-original-case
emailChanged: false, // values are what get returned/sent as the actual OTP destination and saved value — only the
dobChanged: false, // *comparison* (below) is further case-normalized for email, so we never silently rewrite the
oldName: '', // customer's email casing into the saved record or the OTP destination.
newName: '', const newMobile = (form?.get('mobileNo')?.value || '').trim();
oldMobile: '', const newEmail = (form?.get('emailAddress')?.value || '').trim();
newMobile: '', const newDobRaw = form?.get('dateOfBirth')?.value;
oldEmail: '', // Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
newEmail: '', // shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
oldDob: '', // midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
newDob: '' // report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
}; // touched the date field, purely because the Date object round-tripped through the picker.
}
const mobileControl = form.get('mobileNo');
const emailControl = form.get('emailAddress');
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as a change
const newMobile = (mobileControl?.value || '').toString().trim();
const newEmail = (emailControl?.value || '').toString().trim();
const newDobRaw = form.get('dateOfBirth')?.value;
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || ''; const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
const oldMobile = (originalData.mobileNo || '').toString().trim(); const oldMobile = (originalData.mobileNo || '').trim();
const oldEmail = (originalData.emailAddress || '').toString().trim(); const oldEmail = (originalData.emailAddress || '').trim();
const oldDobRaw = originalData.dateOfBirth; const oldDobRaw = originalData.dateOfBirth;
const oldDob = Array.isArray(oldDobRaw) const oldDob = Array.isArray(oldDobRaw)
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}` ? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
@@ -733,10 +669,10 @@ export class EditClientComponent {
).trim(); ).trim();
const newName = buildName({ const newName = buildName({
firstname: form.get('firstname')?.value || '', firstname: form?.get('firstname')?.value || '',
middlename: form.get('middlename')?.value || '', middlename: form?.get('middlename')?.value || '',
lastname: form.get('lastname')?.value || '', lastname: form?.get('lastname')?.value || '',
fullname: form.get('fullname')?.value || '' fullname: form?.get('fullname')?.value || ''
}); });
const oldName = buildName({ const oldName = buildName({
firstname: originalData.firstname || '', firstname: originalData.firstname || '',
@@ -745,21 +681,12 @@ export class EditClientComponent {
fullname: originalData.fullname || '' fullname: originalData.fullname || ''
}); });
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
const normPhone = (val: string) => val.replace(/\D/g, '');
const newMobileNorm = normPhone(newMobile);
const oldMobileNorm = normPhone(oldMobile);
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND normalized values differ
const mobileChanged =
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
const emailChanged =
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
return { return {
nameChanged: newName !== oldName, nameChanged: newName !== oldName,
mobileChanged, mobileChanged: newMobile !== oldMobile,
emailChanged, // Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
dobChanged: newDob !== oldDob, dobChanged: newDob !== oldDob,
oldName, oldName,
newName, newName,
-175
View File
@@ -1,175 +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 {
deriveKycExceptionFields,
KycExceptionInput,
KYC_EXCEPTION_AADHAAR,
KYC_EXCEPTION_PAN
} from './kyc-exception.util';
const TODAY = '05 August 2026';
/** Individual client, nothing previously recorded — the client-creation case. */
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: false,
previousReason: null,
todayFormatted: TODAY
});
/** Individual client that already carries an open exception. */
const existingException = (
panVerified: boolean,
aadhaarVerified: boolean,
previousReason: string
): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: true,
previousReason,
todayFormatted: TODAY
});
describe('deriveKycExceptionFields', () => {
describe('at client creation', () => {
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
isException: true,
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only PAN when Aadhaar verified but PAN did not', () => {
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('raises nothing when both verify, and does not mark the client resolved', () => {
// A clean client never had an exception, so it must not appear in the Resolved tab either.
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('does not raise an Aadhaar exception for a non-individual client', () => {
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
expect(
deriveKycExceptionFields({
panVerified: true,
aadhaarVerified: false,
aadhaarApplicable: false,
hadException: false,
previousReason: null,
todayFormatted: TODAY
}).isException
).toBe(false);
});
});
describe('resolving', () => {
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
isException: false,
exceptionReason: both,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
it('narrows to the outstanding item when only one of two verifies', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('narrows the other way round too', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
describe('re-opening', () => {
it('clears a prior resolution so a new exception returns to Pending', () => {
// Regression: without exceptionResolved=false the record still matched the Resolved query
// and a brand-new exception appeared as already closed.
const result = deriveKycExceptionFields({
panVerified: false,
aadhaarVerified: true,
aadhaarApplicable: true,
hadException: false,
previousReason: 'PAN Card Verification Failed',
todayFormatted: TODAY
});
expect(result.exceptionResolved).toBe(false);
expect(result.exceptionResolvedOn).toBeNull();
expect(result.isException).toBe(true);
});
});
describe('officer-entered reasons', () => {
it('keeps a manual reason alongside the automatic ones', () => {
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: ['Address proof pending']
});
expect(result).toEqual({
isException: true,
exceptionReason: 'Address proof pending',
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('drops a stale automatic reason left in the form once the check passes', () => {
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
// verified, so it must not survive into the payload.
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.isException).toBe(false);
expect(result.exceptionReason).toBeNull();
});
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
});
-112
View File
@@ -1,112 +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/.
*/
/**
* Derives a client's KYC exception state from their verification status.
*
* A KYC exception is not something an officer should have to remember to tick — it is simply the
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
* and Aadhaar are verified at the moment of the save.
*
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
*/
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
KYC_EXCEPTION_PAN,
KYC_EXCEPTION_AADHAAR
];
export interface KycExceptionInput {
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
panVerified: boolean;
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
aadhaarVerified: boolean;
/**
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
*/
aadhaarApplicable: boolean;
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
hadException: boolean;
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
previousReason: string | null;
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
manualReasons?: string[];
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
todayFormatted: string;
}
export interface KycExceptionFields {
isException: boolean;
exceptionReason: string | null;
exceptionResolved: boolean;
exceptionResolvedOn: string | null;
}
/**
* Maps verification status to the four exception columns the API expects.
*
* Three outcomes:
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
* Aadhaar alone, which is what narrows the entry rather than closing it.
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
* why the exception was ever raised.
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
* in either tab.
*/
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
const outstanding: string[] = [];
if (!input.panVerified) {
outstanding.push(KYC_EXCEPTION_PAN);
}
if (input.aadhaarApplicable && !input.aadhaarVerified) {
outstanding.push(KYC_EXCEPTION_AADHAAR);
}
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
for (const reason of input.manualReasons || []) {
const trimmed = (reason || '').trim();
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
outstanding.push(trimmed);
}
}
if (outstanding.length > 0) {
return {
isException: true,
exceptionReason: outstanding.join(', '),
exceptionResolved: false,
exceptionResolvedOn: null
};
}
if (input.hadException) {
return {
isException: false,
exceptionReason: input.previousReason || null,
exceptionResolved: true,
exceptionResolvedOn: input.todayFormatted
};
}
return {
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
};
}
+156 -98
View File
@@ -99,19 +99,6 @@
</svg> </svg>
My Disbursements My Disbursements
</a> </a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
[routerLink]="['/clerk/share-withdrawal-requests']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9" />
<path
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
/>
</svg>
My Share Requests
</a>
@if (clerkAccountLoaded) { @if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap"> <div class="cashier-nav__item-wrap">
<a <a
@@ -447,10 +434,6 @@
<fa-icon icon="piggy-bank" class="ni-ico"></fa-icon>Deposits <fa-icon icon="piggy-bank" class="ni-ico"></fa-icon>Deposits
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'"> <a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances <fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
@@ -463,6 +446,10 @@
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA <fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
<fa-icon icon="caret-down" class="ni-caret"></fa-icon> <fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a> </a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a <a
class="ni" class="ni"
tabindex="0" tabindex="0"
@@ -607,12 +594,6 @@
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0"> <button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report <i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button> </button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
</mat-menu> </mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup, <!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -700,6 +681,7 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')" [class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
routerLink="/cashier/cash-clearing-transfer-summary"
(click)="subLedgerOpen = false" (click)="subLedgerOpen = false"
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a ><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
> >
@@ -748,8 +730,11 @@
</div> </div>
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">Deposits</div> <div class="gl-group-title">Deposits</div>
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')" (click)="subLedgerOpen = false" <a
><fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD / RD Entry</a class="gl-link"
[class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')"
(click)="subLedgerOpen = false; openFdRdEntryDialog()"
><fa-icon icon="piggy-bank" class="gl-icon" style="color: #0f766e"></fa-icon> FD / RD Entry</a
> >
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false" <a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a ><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
@@ -855,50 +840,72 @@
<div class="gl-columns"> <div class="gl-columns">
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">Financial Statements</div> <div class="gl-group-title">Financial Statements</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false" <a
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Trial Balance</a class="gl-link"
[class.hidden]="!matchesGlSearch('Trial Balance')"
routerLink="/gl/reports/trial-balance"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Profit and Loss')" [class.hidden]="!matchesGlSearch('Profit and Loss')"
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')" routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a ><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')" [class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')" routerLink="/gl/reports/balance-sheet"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a (click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidated Balance Sheet</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')" [class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')" routerLink="/gl/reports/comparative-balance-sheet"
(click)="glMenuOpen = false"
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance ><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
Sheet</a Sheet</a
> >
</div> </div>
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">GL Reports</div> <div class="gl-group-title">GL Reports</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false" <a
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a class="gl-link"
[class.hidden]="!matchesGlSearch('GL Average Balance')"
routerLink="/gl/reports/gl-average-balance"
(click)="glMenuOpen = false"
><fa-icon icon="calculator" class="gl-icon" style="color: #b45309"></fa-icon> GL Average Balance</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false" <a
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a class="gl-link"
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
routerLink="/gl/reports/gl-mapping"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Mapping for
Report</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Account wise Balance List')" [class.hidden]="!matchesGlSearch('Account wise Balance List')"
routerLink="/gl/reports/account-wise-balance"
(click)="glMenuOpen = false" (click)="glMenuOpen = false"
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</a ><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false" <a
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a class="gl-link"
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
routerLink="/gl/reports/distribution-of-balance"
(click)="glMenuOpen = false"
><fa-icon icon="chart-pie" class="gl-icon" style="color: #b45309"></fa-icon> Distribution of Balance</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('GL Wise DR CR')" [class.hidden]="!matchesGlSearch('GL Wise DR CR')"
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')" routerLink="/gl/reports/gl-wise-dr-cr"
(click)="glMenuOpen = false"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR ><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
Report</a Report</a
> >
@@ -908,28 +915,47 @@
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Client wise Balance Report')" [class.hidden]="!matchesGlSearch('Client wise Balance Report')"
routerLink="/gl/reports/client-wise-balance"
(click)="glMenuOpen = false" (click)="glMenuOpen = false"
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a ><fa-icon icon="users" class="gl-icon" style="color: #b45309"></fa-icon> Client wise Balance Report</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false" <a
><fa-icon icon="exclamation-circle" class="gl-icon"></fa-icon> Contingent Expense</a class="gl-link"
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
routerLink="/gl/reports/contingent-liabilities"
(click)="glMenuOpen = false"
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
Liabilities</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false" <a
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a class="gl-link"
[class.hidden]="!matchesGlSearch('Banker Head Report')"
routerLink="/gl/reports/banker-head"
(click)="glMenuOpen = false"
><fa-icon icon="university" class="gl-icon" style="color: #b45309"></fa-icon> Banker Head Report</a
> >
</div> </div>
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">Registers</div> <div class="gl-group-title">Registers</div>
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false" <a
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a class="gl-link"
[class.hidden]="!matchesGlSearch('Sundry Register')"
routerLink="/gl/reports/sundry-register"
(click)="glMenuOpen = false"
><fa-icon icon="clipboard-list" class="gl-icon" style="color: #b45309"></fa-icon> Sundry Register</a
> >
<a class="gl-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false" <a
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a class="gl-link"
[class.hidden]="!matchesGlSearch('Voucher Register')"
routerLink="/gl/reports/voucher-register"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Voucher Register</a
> >
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')" [class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')" routerLink="/gl/reports/misc-expense-ledger"
(click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a ><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
> >
</div> </div>
@@ -1202,7 +1228,7 @@
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry <fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
</button> </button>
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button> <button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
<button mat-menu-item tabindex="0"> <button mat-menu-item routerLink="/investment/bonds" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report <fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
</button> </button>
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()"> <button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
@@ -1218,13 +1244,13 @@
<!-- Investment --> <!-- Investment -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Investment</div> <div class="deposit-group-title">Investment</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment <fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report <fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item routerLink="/investment/reports" tabindex="0">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment <fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
</button> </button>
<button mat-menu-item routerLink="/deaf" tabindex="0"> <button mat-menu-item routerLink="/deaf" tabindex="0">
@@ -1255,39 +1281,63 @@
<!-- NPA Reports --> <!-- NPA Reports -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">NPA Reports</div> <div class="deposit-group-title">NPA Reports</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest <fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report <fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report <fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report <fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
</button> </button>
</div> </div>
<!-- NPA Analysis --> <!-- NPA Analysis -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">NPA Analysis</div> <div class="deposit-group-title">NPA Analysis</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report <fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report <fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision <fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
</button> </button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
</button>
</div> </div>
<!-- NPA Classification --> <!-- NPA Classification -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">NPA Classification</div> <div class="deposit-group-title">NPA Classification</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA <fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
</button> </button>
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button> <button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
</button>
</div>
<!-- Recovery Management -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Recovery Management</div>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
</button>
</div> </div>
</div> </div>
</mat-menu> </mat-menu>
@@ -1353,27 +1403,27 @@
<!-- Core / Books --> <!-- Core / Books -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div> <div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation Voucher Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation Sundry Dr / Cr Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation P&amp;L Impact Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation GL Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation Fixed Assets Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation Inter-Company Reconciliation
</button> </button>
@@ -1382,19 +1432,19 @@
<!-- Bank & Clearing --> <!-- Bank & Clearing -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div> <div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation Bank Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation API Based Bank Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation NEFT / RTGS Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation Clearing Reconciliation
</button> </button>
@@ -1403,15 +1453,15 @@
<!-- Digital / Payments --> <!-- Digital / Payments -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div> <div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM) UDIR (IMPS / UPI / ATM)
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation Three-way Digital Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation NACH Reconciliation
</button> </button>
@@ -1424,7 +1474,7 @@
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation DD / Pay Order Reconciliation
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation Share Member Reconciliation
</button> </button>
@@ -1433,11 +1483,11 @@
<!-- Compliance & Financials --> <!-- Compliance & Financials -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div> <div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment Invoice Payment
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation Tax Reconciliation
</button> </button>
@@ -1549,17 +1599,12 @@
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98 <fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
</button> </button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97/98 Txn Report
</button>
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0"> <button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121 <fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button> </button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &amp;
TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
Govt
</button>
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0"> <button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon> <fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
Hypothecation Certificate Hypothecation Certificate
@@ -1761,28 +1806,41 @@
> >
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML <fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a> </a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
routerLink="/reports/risk-review"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
</a>
</div> </div>
<!-- OSS Reports --> <!-- OSS Reports -->
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">OSS Reports</div> <div class="gl-group-title">OSS Reports</div>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" (click)="rbiMenuOpen = false"> <a
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 1 XBRL/CIMS class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
routerLink="/rbi/oss1"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
</a> </a>
<a <a
class="gl-link" class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')" [class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')" routerLink="/rbi/oss2"
(click)="rbiMenuOpen = false"
> >
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL <fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
</a> </a>
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" (click)="rbiMenuOpen = false"> <a
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 3 XBRL/CIMS class="gl-link"
</a> [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 4/OSS 5/OSS 6')" (click)="rbiMenuOpen = false"> routerLink="/rbi/oss3"
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 4 / OSS 5 / OSS 6 (click)="rbiMenuOpen = false"
</a> >
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 7')" (click)="rbiMenuOpen = false"> <fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 7
</a> </a>
</div> </div>
<!-- CIMS / XBRL Returns --> <!-- CIMS / XBRL Returns -->
+2 -2
View File
@@ -188,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy {
return this.router.url; return this.router.url;
} }
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */ /** True on the manager's full-screen withdrawal-request review screen. */
get isWithdrawalReviewUrl(): boolean { get isWithdrawalReviewUrl(): boolean {
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url); return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
} }
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */ /** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
@@ -29,11 +29,6 @@
Account Status Account Status
<span class="km-cnt km-cnt--red">34</span> <span class="km-cnt km-cnt--red">34</span>
</button> </button>
<button class="km-itab" [class.active]="activeSection === 'exceptions'" (click)="showSection('exceptions')">
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exceptions
<span class="km-cnt km-cnt--amb">{{ exceptionRows.length }}</span>
</button>
</div> </div>
<div class="km-main"> <div class="km-main">
@@ -727,284 +722,6 @@
</div> </div>
</div> </div>
} }
<!-- KYC EXCEPTIONS -->
@if (activeSection === 'exceptions') {
<div class="km-page">
<div class="km-page-head">
<span class="km-page-title">KYC Exceptions Dashboard</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
<fa-icon icon="download"></fa-icon> Export Exceptions
</button>
</div>
</div>
<div class="km-stat-row">
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-amber)">{{ exceptionRows.length }}</div>
<div class="km-stat-l">Pending Exceptions</div>
<div class="km-stat-t" style="color: var(--km-amber)">Immediate attention</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-red)">
{{ notYetContactedCount }}
</div>
<div class="km-stat-l">Not Yet Contacted</div>
<div class="km-stat-t" style="color: var(--km-red)">Action required</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-orange)">
{{ panMismatchesCount }}
</div>
<div class="km-stat-l">PAN Mismatches</div>
<div class="km-stat-t" style="color: var(--km-orange)">Requires physical review</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-blue)">
{{ aadhaarFailuresCount }}
</div>
<div class="km-stat-l">Aadhaar Failures</div>
<div class="km-stat-t" style="color: var(--km-blue)">Re-KYC OTP needed</div>
</div>
</div>
<div class="km-card km-mt">
<!-- Tab Toggle Header -->
<div
class="km-ch"
style="
border-bottom: 1px solid var(--km-g200);
padding-bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
"
>
<div style="display: flex; gap: 8px; align-items: center">
<div class="km-ci" style="background: #fef3c7; color: #d97706">
<fa-icon icon="exclamation-triangle"></fa-icon>
</div>
<div class="km-ct">Exceptions Register</div>
</div>
<div class="km-inner-nav" style="width: 100%; border: none; margin: 10px 0 0 0; padding: 0; gap: 8px">
<button
class="km-itab"
[class.active]="activeExceptionSubTab === 'pending'"
(click)="activeExceptionSubTab = 'pending'"
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
>
<fa-icon icon="clock"></fa-icon>
Pending Exceptions ({{ pendingExceptions.length }})
</button>
<button
class="km-itab"
[class.active]="activeExceptionSubTab === 'resolved'"
(click)="activeExceptionSubTab = 'resolved'"
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
>
<fa-icon icon="check-circle"></fa-icon>
Resolved Exceptions ({{ resolvedExceptions.length }})
</button>
</div>
</div>
<!-- PENDING EXCEPTIONS TAB -->
@if (activeExceptionSubTab === 'pending') {
@if (pendingExceptions.length === 0) {
<div class="km-empty" style="padding: 40px; text-align: center">
<div class="km-empty-i">🎉</div>
<div style="font-weight: 500; font-size: 16px; margin-top: 12px">All exceptions resolved!</div>
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
There are no active client KYC exceptions on record.
</div>
</div>
} @else {
<table class="km-tbl">
<thead>
<tr>
<th>Client / Account</th>
<th>Branch</th>
<th>Exception Reason &amp; Date</th>
<th>Risk</th>
<th>Contact Status</th>
<th>Contact Logs</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (c of pendingExceptions; track c.id) {
<tr>
<td>
<div
class="km-an"
[routerLink]="['/clients', c.id, 'kyc']"
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
>
{{ c.name }}
</div>
<div class="km-ai">{{ c.account }}</div>
</td>
<td>{{ c.contact.branch }}</td>
<td>
<div style="font-weight: 500; color: #b45309">{{ c.exceptionReason }}</div>
<div class="km-small">Opened: {{ c.exceptionDate }}</div>
</td>
<td>
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<span class="km-del d-ok">Contacted</span>
<div class="km-small" style="margin-top: 2px">
Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}
</div>
} @else {
<span class="km-del d-fa">Not Contacted</span>
}
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
@for (log of c.contactHistory; track $index) {
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
<em>— {{ log.officer }}</em>
@if (log.platformSent) {
@if (log.delivered) {
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
>Sent {{ log.destinationMasked }}</span
>
} @else {
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
}
}
</div>
}
</div>
} @else {
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
}
</td>
<td>
<div style="display: flex; gap: 8px">
<button
class="km-btn km-btn-ot"
style="border-color: var(--km-blue); color: var(--km-blue)"
[routerLink]="['/clients', c.id, 'kyc']"
>
<fa-icon icon="user-check"></fa-icon> Do KYC
</button>
<button class="km-btn km-btn-ot" (click)="openContactDialog(c)">
<fa-icon icon="phone"></fa-icon> Log Call
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
}
}
<!-- RESOLVED EXCEPTIONS TAB -->
@if (activeExceptionSubTab === 'resolved') {
@if (resolvedExceptions.length === 0) {
<div class="km-empty" style="padding: 40px; text-align: center">
<div class="km-empty-i" style="font-size: 32px; filter: grayscale(1)">📋</div>
<div style="font-weight: 500; font-size: 16px; margin-top: 12px; color: var(--km-g500)">
No resolved exceptions
</div>
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
Resolved KYC exceptions will appear here once verification is completed.
</div>
</div>
} @else {
<table class="km-tbl">
<thead>
<tr>
<th>Client / Account</th>
<th>Branch</th>
<th>Exception Reason &amp; Resolution</th>
<th>Risk</th>
<th>Contact Logs</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (c of resolvedExceptions; track c.id) {
<tr>
<td>
<div
class="km-an"
[routerLink]="['/clients', c.id, 'kyc']"
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
>
{{ c.name }}
</div>
<div class="km-ai">{{ c.account }}</div>
</td>
<td>{{ c.contact.branch }}</td>
<td>
<div style="font-weight: 500; color: var(--km-g500); text-decoration: line-through">
{{ c.exceptionReason }}
</div>
<div class="km-small">Resolved On: {{ c.exceptionResolvedOn }}</div>
</td>
<td>
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
@for (log of c.contactHistory; track $index) {
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
<em>— {{ log.officer }}</em>
@if (log.platformSent) {
@if (log.delivered) {
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
>Sent {{ log.destinationMasked }}</span
>
} @else {
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
}
}
</div>
}
</div>
} @else {
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
}
</td>
<td>
<span
style="
display: inline-flex;
align-items: center;
gap: 4px;
background: #e6f4ea;
color: #137333;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
font-size: 12px;
"
>
<fa-icon icon="check-circle"></fa-icon> Exception Resolved
</span>
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
</div>
}
</div> </div>
<!-- CLIENT DETAIL MODAL --> <!-- CLIENT DETAIL MODAL -->
@@ -1448,251 +1165,6 @@
</div> </div>
} }
<!-- LOG CONTACT DIALOG -->
@if (contactDialogOpen && current) {
<div class="km-overlay" (click)="closeContactDialog()">
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
<div class="km-mh">
<div class="km-mh-av">{{ current.av }}</div>
<div>
<div class="km-mh-name">Log Customer Contact</div>
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
</div>
<div class="km-mclose" (click)="closeContactDialog()">✕</div>
</div>
<div class="km-mbody hv-body">
<div class="hv-addr">
<div class="hv-addr-label">Customer Contact Info</div>
<div class="hv-addr-name">{{ current.name }}</div>
<div class="hv-addr-text">
<fa-icon icon="mobile-alt" class="vkyc-ic"></fa-icon> {{ current.contact.mobile }}
</div>
<div class="hv-addr-text">
<fa-icon icon="envelope" class="vkyc-ic"></fa-icon> {{ current.contact.email }}
</div>
</div>
<div class="hv-row">
<label class="hv-label">Contact Channel<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="contactChannel">
@for (ch of contactChannels; track ch.value) {
<option [value]="ch.value">
{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}
</option>
}
</select>
@if (selectedChannelSends) {
<div class="hv-hint hv-hint-send">
<fa-icon icon="paper-plane" class="vkyc-ic"></fa-icon>
A KYC notice will be sent to the customer when you save.
</div>
} @else {
<div class="hv-hint">Records an action you already took. Nothing is sent to the customer.</div>
}
@if (selectedChannelUnavailable) {
<div class="hv-hint hv-hint-error">
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on
file — choose a different channel.
</div>
}
</div>
<div class="hv-row">
<label class="hv-label">KYC Officer Logging<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="contactOfficer">
@for (officer of bankOfficers; track officer) {
<option [value]="officer">{{ officer }}</option>
}
</select>
</div>
<div class="hv-row">
<label class="hv-label">Summary / Notes<span class="hv-req">*</span></label>
<textarea
class="hv-input hv-textarea"
rows="3"
placeholder="Enter details of conversation e.g. Customer promised to visit branch next Tuesday..."
[(ngModel)]="contactNotes"
></textarea>
</div>
@if (contactError) {
<div class="hv-hint hv-hint-error" role="alert">
<fa-icon icon="exclamation-circle" class="vkyc-ic"></fa-icon> {{ contactError }}
</div>
}
</div>
<div class="hv-footer">
<button class="km-btn" [disabled]="contactSaving" (click)="closeContactDialog()">Cancel</button>
<button class="km-btn km-btn-t" [disabled]="contactSaveDisabled" (click)="saveContactLog()">
{{ contactSaveLabel }}
</button>
</div>
</div>
</div>
}
<!-- RESOLVE KYC DIALOG -->
@if (resolveDialogOpen && current) {
<div class="km-overlay" (click)="closeResolveDialog()">
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
<div class="km-mh" style="border-bottom: 2px solid var(--km-green)">
<div class="km-mh-av" style="background: var(--km-green)">{{ current.av }}</div>
<div>
<div class="km-mh-name">Resolve KYC Exception</div>
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
</div>
<div class="km-mclose" (click)="closeResolveDialog()">✕</div>
</div>
<div class="km-mbody hv-body">
<div
style="
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
font-size: 13px;
color: #166534;
"
>
<strong>Pending Reason:</strong> {{ current.exceptionReason }}
@if (current.exceptionRemarks) {
<div style="margin-top: 4px; font-style: italic">"{{ current.exceptionRemarks }}"</div>
}
</div>
@if (showPanResolution) {
<div
style="
border: 1px solid var(--km-g300);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
background: #fafafa;
"
>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<input type="checkbox" id="resolvePanChk" [(ngModel)]="resolvePanActive" />
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
>Resolve PAN Card Exception</label
>
</div>
@if (resolvePanActive) {
<div class="hv-row" style="margin-left: 20px">
<label class="hv-label">Verified PAN Number<span class="hv-req">*</span></label>
<input
type="text"
class="hv-input"
[(ngModel)]="resolvePanNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase"
/>
@if (resolvePanNumber && resolvePanNumber.length !== 10) {
<div style="color: red; font-size: 11px; margin-top: 4px">PAN must be exactly 10 characters.</div>
}
</div>
}
</div>
}
@if (showAadhaarResolution) {
<div
style="
border: 1px solid var(--km-g300);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
background: #fafafa;
"
>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<input type="checkbox" id="resolveAadhChk" [(ngModel)]="resolveAadhaarActive" />
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
>Resolve Aadhaar Card Exception</label
>
</div>
@if (resolveAadhaarActive) {
<div class="hv-row" style="margin-left: 20px">
<label class="hv-label">Verified Aadhaar Number<span class="hv-req">*</span></label>
<input
type="text"
class="hv-input"
[(ngModel)]="resolveAadhaarNumber"
placeholder="e.g. 597855025299"
maxlength="12"
/>
@if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) {
<div style="color: red; font-size: 11px; margin-top: 4px">Aadhaar must be exactly 12 digits.</div>
}
</div>
}
</div>
}
@if (showGeneralResolution) {
<div
style="
border: 1px solid var(--km-g300);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
background: #fafafa;
"
>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
<input type="checkbox" id="resolveGenChk" [(ngModel)]="resolveGeneralActive" />
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
>Resolve General Exception</label
>
</div>
@if (resolveGeneralActive) {
<div style="margin-left: 20px">
<div class="hv-row">
<label class="hv-label">Verification Document Type<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="resolveGeneralDocumentType">
<option value="OVD">Officially Valid Document (Passport/Voter ID)</option>
</select>
</div>
<div class="hv-row">
<label class="hv-label">Document Identification Number<span class="hv-req">*</span></label>
<input
type="text"
class="hv-input"
[(ngModel)]="resolveGeneralDocumentNumber"
placeholder="Enter verified document number"
/>
</div>
</div>
}
</div>
}
<div class="hv-row">
<label class="hv-label">Resolution Comments<span class="hv-req">*</span></label>
<textarea
class="hv-input hv-textarea"
rows="3"
placeholder="Describe how verification was resolved..."
[(ngModel)]="resolveRemarks"
></textarea>
</div>
</div>
<div class="hv-footer">
<button class="km-btn" (click)="closeResolveDialog()">Cancel</button>
<button class="km-btn km-btn-g" [disabled]="!isResolveValid" (click)="saveResolveKyc()">
Complete KYC &amp; Resolve Exception
</button>
</div>
</div>
</div>
}
<!-- TOAST --> <!-- TOAST -->
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div> <div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div> </div>
@@ -1588,58 +1588,6 @@
min-height: 60px; min-height: 60px;
} }
/* Inline note under a field. The default (neutral) form states that nothing is transmitted;
the -send and -error variants call out the two cases the officer must not misread. */
.hv-hint {
font-size: 11px;
line-height: 1.45;
color: var(--km-g500);
display: flex;
align-items: flex-start;
gap: 6px;
}
.hv-hint-send {
color: #0f766e;
background: #f0fdfa;
border: 1px solid #99f6e4;
border-radius: 4px;
padding: 6px 8px;
font-weight: 500;
}
.hv-hint-error {
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 4px;
padding: 6px 8px;
font-weight: 500;
}
/* Delivery outcome chip on a contact-log row. A logged attempt and a delivered notice are
different facts, so they never share a colour. */
.km-sent-tag {
display: inline-block;
margin-left: 6px;
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
vertical-align: baseline;
}
.km-sent-ok {
background: #e6f4ea;
color: #137333;
}
.km-sent-fail {
background: #fef2f2;
color: #b91c1c;
}
.hv-footer { .hv-footer {
padding: 14px 22px; padding: 14px 22px;
border-top: 1px solid var(--km-g200); border-top: 1px solid var(--km-g200);
@@ -1700,28 +1648,3 @@
color: #0f172a; color: #0f172a;
word-break: break-all; word-break: break-all;
} }
/* KYC Exceptions Styles */
.km-ci-e {
background: var(--km-amber-l) !important;
color: var(--km-amber) !important;
}
.contact-logs-list {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
font-family: inherit;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
}
@@ -6,11 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from '../clients/clients.service';
interface KmNotice { interface KmNotice {
no: string; no: string;
@@ -32,24 +31,6 @@ interface KmEvidence {
docket?: string; docket?: string;
} }
/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */
interface KmContactLog {
date: string;
/** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */
action: string;
notes: string;
officer: string;
/** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */
platformSent?: boolean;
/** True only when the notice actually reached the gateway. */
delivered?: boolean;
/** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */
deliveryStatus?: string;
/** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */
deliveryMessage?: string;
destinationMasked?: string;
}
interface KmClient { interface KmClient {
id: string; id: string;
av: string; av: string;
@@ -85,17 +66,6 @@ interface KmClient {
pendingActions: string[]; pendingActions: string[];
actionTaken: string[]; actionTaken: string[];
evidence: KmEvidence | null; evidence: KmEvidence | null;
isException?: boolean;
exceptionReason?: string;
exceptionRemarks?: string;
exceptionDate?: string;
exceptionResolved?: boolean;
exceptionResolvedOn?: string;
contactHistory?: KmContactLog[];
kycId?: string;
/** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */
hasMobile?: boolean;
hasEmail?: boolean;
} }
@Component({ @Component({
@@ -109,8 +79,7 @@ interface KmClient {
] ]
}) })
export class KycManagementComponent implements OnInit { export class KycManagementComponent implements OnInit {
private clientsService = inject(ClientsService); activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts';
activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts';
modalOpen = false; modalOpen = false;
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview'; modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
@@ -560,399 +529,15 @@ export class KycManagementComponent implements OnInit {
}; };
alertRows: KmClient[] = []; alertRows: KmClient[] = [];
exceptionRows: KmClient[] = [];
pendingExceptions: KmClient[] = [];
resolvedExceptions: KmClient[] = [];
activeExceptionSubTab: 'pending' | 'resolved' = 'pending';
selectedExceptionReason = '';
selectedExceptionContactStatus = '';
// Dialog properties for exceptions
contactDialogOpen = false;
contactChannel = 'PHONE_CALL';
contactNotes = '';
contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)';
/** In-flight guard — a double-click must not send the customer two notices. */
contactSaving = false;
/** Populated when the save itself failed (not when the save worked but delivery didn't). */
contactError = '';
/**
* Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving
* transmits a real notice to the customer, as opposed to recording an action the officer already
* took offline — the dialog labels and the confirm button both key off it.
*/
readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [
{ value: 'PHONE_CALL', label: 'Phone Call', sends: false },
{ value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' },
{ value: 'EMAIL', label: 'Email', sends: true, requires: 'email' },
{ value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' },
{ value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false },
{ value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false }
];
resolveDialogOpen = false;
resolveDocumentType = 'PAN';
resolveDocumentNumber = '';
resolveRemarks = '';
resolvePanActive = false;
resolvePanNumber = '';
resolveAadhaarActive = false;
resolveAadhaarNumber = '';
resolveGeneralActive = false;
resolveGeneralDocumentType = 'OVD';
resolveGeneralDocumentNumber = '';
showPanResolution = false;
showAadhaarResolution = false;
showGeneralResolution = false;
ngOnInit(): void { ngOnInit(): void {
this.alertRows = Object.values(this.clients); this.alertRows = Object.values(this.clients);
this.loadExceptions();
} }
loadExceptions(): void { showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void {
this.clientsService.getKycExceptions(false).subscribe({
next: (data) => {
this.pendingExceptions = data.map((item: any) => this.mapException(item, false));
this.exceptionRows = this.pendingExceptions;
this.loadContactHistories(this.pendingExceptions);
},
error: (err) => {
console.error('Failed to load active exceptions', err);
this.pendingExceptions = [];
this.exceptionRows = [];
}
});
this.clientsService.getKycExceptions(true).subscribe({
next: (data) => {
this.resolvedExceptions = data.map((item: any) => this.mapException(item, true));
this.loadContactHistories(this.resolvedExceptions);
},
error: (err) => {
console.error('Failed to load resolved exceptions', err);
this.resolvedExceptions = [];
}
});
}
private mapException(item: any, resolved: boolean): KmClient {
return {
id: item.clientId?.toString(),
av: (item.clientDisplayName || 'Client').split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase(),
name: item.clientDisplayName,
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
account: item.clientAccountNo || `CLI-${item.clientId}`,
state: 'alert',
risk: item.riskCategoryCode?.toLowerCase() || 'low',
riskLabel: item.riskCategory || 'Low Risk',
expiry: 'Pending',
daysLeft: 30,
kyc: {
type: item.kycType || 'Individual',
initiated: item.initiatedOn || '—',
lastVerified: item.verifiedOn || '—',
validity: '—',
pan: item.panNumber || '—',
aadhaar: item.aadhaarNumberMasked || '—',
panStatus: item.panVerified ? 'Verified' : 'Verification Failed',
aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed',
panLink: 'Not Linked'
},
contact: {
// Masked server-side; the full values never reach the browser. 'Not on file' is meaningful
// here — it's why an SMS or Email channel will be unavailable for this customer.
mobile: item.clientMobileMasked || 'Not on file',
email: item.clientEmailMasked || 'Not on file',
address: 'Client Address, Bhopal MP 462002',
branch: item.clientOfficeName || 'Bhopal Main'
},
notices: [],
noticesSent: 0,
noticePending: resolved ? 'Resolved Exception' : 'Pending verification',
nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review',
pendingActions: resolved ? [] : ['Resolve Exception reasons'],
actionTaken: resolved ? ['KYC Exception resolved'] : [],
evidence: null,
isException: true,
// No invented default — a record whose reason was never stored (or was cleared by the old
// resolve path) must not be relabelled as a PAN failure it may never have had.
exceptionReason: item.exceptionReason || 'Reason not recorded',
exceptionRemarks: item.remarks || '',
exceptionDate: item.initiatedOn || '—',
exceptionResolved: resolved,
exceptionResolvedOn: item.exceptionResolvedOn || '—',
contactHistory: [],
kycId: item.id?.toString(),
hasMobile: item.hasMobile === true,
hasEmail: item.hasEmail === true
};
}
/**
* Pull each row's persisted outreach history. Fired after the exception list loads; a failure on
* one row leaves that row's history empty rather than blanking the whole grid.
*/
private loadContactHistories(rows: KmClient[]): void {
for (const row of rows) {
if (!row.id) continue;
this.clientsService.getKycContactLog(row.id).subscribe({
next: (logs) => {
row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log));
},
error: (err) => {
console.error(`Failed to load contact log for client ${row.id}`, err);
}
});
}
}
get notYetContactedCount(): number {
return this.exceptionRows.filter(c => !c.contactHistory || c.contactHistory.length === 0).length;
}
get panMismatchesCount(): number {
return this.exceptionRows.filter(c => c.exceptionReason?.includes('PAN')).length;
}
get aadhaarFailuresCount(): number {
return this.exceptionRows.filter(c => c.exceptionReason?.includes('Aadhaar')).length;
}
showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void {
this.activeSection = section; this.activeSection = section;
} }
openContactDialog(client: KmClient): void {
this.current = client;
this.contactChannel = 'PHONE_CALL';
this.contactNotes = '';
this.contactError = '';
this.contactSaving = false;
this.contactOfficer = this.bankOfficers[1]; // Neha Verma
this.contactDialogOpen = true;
}
closeContactDialog(): void {
this.contactDialogOpen = false;
}
/** The channel currently picked in the dialog. */
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
return this.contactChannels.find(c => c.value === this.contactChannel);
}
/** True when saving will transmit a notice to the customer, not merely record one. */
get selectedChannelSends(): boolean {
return this.selectedChannel?.sends === true;
}
/**
* True when the picked channel needs a destination the customer doesn't have. Blocks the save so
* the officer isn't left reading a failure they could have seen up front.
*/
get selectedChannelUnavailable(): boolean {
const requires = this.selectedChannel?.requires;
if (!requires || !this.current) return false;
return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false;
}
get contactSaveDisabled(): boolean {
return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable;
}
/** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */
get contactSaveLabel(): string {
if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…';
return this.selectedChannelSends ? 'Send & Save' : 'Save Log';
}
saveContactLog(): void {
if (!this.current || this.contactSaveDisabled) return;
const client = this.current;
this.contactSaving = true;
this.contactError = '';
this.clientsService
.saveKycContactLog(client.id, {
channel: this.contactChannel,
notes: this.contactNotes.trim(),
officerName: this.contactOfficer
})
.subscribe({
next: (log: any) => {
this.contactSaving = false;
client.contactHistory = [...(client.contactHistory || []), this.mapContactLog(log)];
// The request succeeding does not mean the customer was reached — a gateway failure comes
// back as HTTP 200 with delivered=false, so report what actually happened.
if (log?.platformSent && !log?.delivered) {
this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`);
} else {
this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`);
}
this.contactDialogOpen = false;
},
error: (err) => {
this.contactSaving = false;
// Keep the dialog open — the officer's notes are still in the textarea and would
// otherwise be lost.
this.contactError =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'Could not save the contact log. Please try again.';
}
});
}
private mapContactLog(log: any): KmContactLog {
return {
date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—',
action: log?.channelLabel || log?.channel || 'Contact',
notes: log?.notes || '',
officer: (log?.officerName || '').split(' — ')[0],
platformSent: log?.platformSent,
delivered: log?.delivered,
deliveryStatus: log?.deliveryStatus,
deliveryMessage: log?.deliveryMessage,
destinationMasked: log?.destinationMasked
};
}
/**
* `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a
* [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both.
*/
private formatLogDate(value: any): string {
const date = Array.isArray(value)
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
: new Date(value);
if (isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
get isResolveValid(): boolean {
if (!this.resolveRemarks?.trim()) return false;
let anyActive = false;
if (this.showPanResolution && this.resolvePanActive) {
anyActive = true;
if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false;
}
if (this.showAadhaarResolution && this.resolveAadhaarActive) {
anyActive = true;
if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false;
}
if (this.showGeneralResolution && this.resolveGeneralActive) {
anyActive = true;
if (!this.resolveGeneralDocumentNumber?.trim()) return false;
}
return anyActive;
}
openResolveDialog(client: KmClient): void {
this.current = client;
const reason = client.exceptionReason || '';
this.showPanResolution = reason.includes('PAN');
this.showAadhaarResolution = reason.includes('Aadhaar');
this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution;
this.resolvePanActive = this.showPanResolution;
this.resolveAadhaarActive = this.showAadhaarResolution;
this.resolveGeneralActive = this.showGeneralResolution;
this.resolvePanNumber = '';
this.resolveAadhaarNumber = '';
this.resolveGeneralDocumentType = 'OVD';
this.resolveGeneralDocumentNumber = '';
this.resolveRemarks = '';
this.resolveDialogOpen = true;
}
closeResolveDialog(): void {
this.resolveDialogOpen = false;
}
saveResolveKyc(): void {
if (!this.current || !this.current.kycId) return;
const clientId = this.current.id;
const kycId = this.current.kycId;
const today = new Date();
const day = today.getDate().toString().padStart(2, '0');
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const monthStr = months[today.getMonth()];
const year = today.getFullYear();
const dateStr = `${day} ${monthStr} ${year}`;
const resolvingPan = this.showPanResolution && this.resolvePanActive;
const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive;
const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive;
const remainsPan = this.showPanResolution && !this.resolvePanActive;
const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive;
const isFullyResolved = !remainsPan && !remainsAadhaar;
const resolvedKycData: any = {
locale: 'en',
dateFormat: 'dd MMMM yyyy',
remarks: this.resolveRemarks || 'Resolved via exceptions dashboard'
};
if (isFullyResolved) {
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys
// the audit record of why the exception was ever raised.
} else {
resolvedKycData.isException = true;
resolvedKycData.exceptionResolved = false;
const remainingReasons: string[] = [];
if (remainsPan) remainingReasons.push('PAN Card Verification Failed');
if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed');
resolvedKycData.exceptionReason = remainingReasons.join(', ');
}
if (resolvingPan) {
resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim();
resolvedKycData.panVerified = true;
}
if (resolvingAadhaar) {
const aadhaarRaw = this.resolveAadhaarNumber.trim();
resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4);
resolvedKycData.aadhaarVerified = true;
}
if (resolvingGeneral) {
resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType;
resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber;
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// exceptionReason preserved — see the fully-resolved branch above.
}
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
next: () => {
const msg = isFullyResolved
? `KYC Exception fully resolved and completed for ${this.current?.name}`
: `KYC Exception partially resolved for ${this.current?.name}`;
this.toast(msg);
this.loadExceptions();
this.resolveDialogOpen = false;
},
error: (err) => {
console.error('Failed to resolve exception', err);
this.toast('Failed to resolve exception. Please try again.');
}
});
}
openModal(id: string): void { openModal(id: string): void {
const c = this.clients[id]; const c = this.clients[id];
if (!c) return; if (!c) return;
@@ -1069,10 +1069,12 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
y = paintPara( y = paintPara(
c, c,
y, y,
`Declaration under Section ${kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' `Declaration under Section ${
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${kind === '15H' kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
? 'applicable to resident individuals aged 60 years or above' } of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${
: 'applicable to resident individuals (other than companies / firms) below 60 years' kind === '15H'
? 'applicable to resident individuals aged 60 years or above'
: 'applicable to resident individuals (other than companies / firms) below 60 years'
}.` }.`
); );
@@ -505,72 +505,3 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--cert-g900); color: var(--cert-g900);
} }
/* Form 121 register — eligibility warnings and the quarter-wise drill-down */
/* A row the branch needs to look at: an ineligible declaration, or the quarter
in which the 194A threshold was crossed. */
.cert-row--warn > td {
background: color-mix(in srgb, var(--cert-amber) 7%, transparent);
}
.cert-row--detail > td {
background: var(--cert-g50);
padding: 16px;
}
.cert-note {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
border-left: 3px solid var(--cert-blue);
font-size: 12px;
color: var(--cert-g900);
line-height: 1.5;
fa-icon {
color: var(--cert-blue);
flex-shrink: 0;
margin-top: 1px;
}
ul {
margin: 6px 0 0;
padding-left: 18px;
}
li + li {
margin-top: 4px;
}
&--warn {
background: color-mix(in srgb, var(--cert-amber) 8%, transparent);
border-left-color: var(--cert-amber);
fa-icon {
color: var(--cert-amber);
}
}
}
/* The quarter table nested inside an expanded row. */
.cert-table--inner {
background: #fff;
border: 1px solid var(--cert-g200);
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
th {
background: #fff;
border-bottom: 1px solid var(--cert-g200);
}
.cert-pill {
margin-left: 6px;
}
}
@@ -11,60 +11,26 @@
<div class="cert-head-l"> <div class="cert-head-l">
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon> <fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
<div> <div>
<h2 class="cert-head-t">{{ statute.form }}</h2> <h2 class="cert-head-t">Form 121</h2>
<div class="cert-head-s"> <div class="cert-head-s">TDS-waiver declarations for FD / RD interest income — Form 121.</div>
@if (viewMode === 'demo') {
Declarations to stop tax being deducted on fixed and recurring deposit interest under
{{ statute.section }}. Reported in {{ statute.statement }}; the consolidated statement of declarations is
uploaded {{ statute.frequency }}.
} @else {
Live customer declarations, sourced from client accounts and KYC profiles.
}
</div>
</div> </div>
</div> </div>
<div class="cert-head-r">
@if (viewMode === 'demo') {
<button
type="button"
class="cert-cta-btn"
(click)="toggleViewMode()"
matTooltip="Use real client KYC and account data from Fineract"
>
<fa-icon icon="database"></fa-icon> Real Declarations
</button>
} @else {
<button
type="button"
class="cert-cta-btn cert-cta-btn--muted"
(click)="toggleViewMode()"
matTooltip="Back to the sample data page"
>
<fa-icon icon="table"></fa-icon> Sample Data
</button>
}
</div>
</div> </div>
@if (viewMode === 'real') { <!-- KPI strip -->
<mifosx-real-form15gh-view></mifosx-real-form15gh-view>
}
@if (viewMode === 'demo') {
<!-- KPI strip -->
<div class="cert-kpis"> <div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue"> <div class="cert-kpi cert-kpi--blue">
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon> <fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
<div> <div>
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div> <div class="cert-kpi-n">{{ count15G() | number }}</div>
<div class="cert-kpi-l">General band (under 60)</div> <div class="cert-kpi-l">Form 121 (under 60)</div>
</div> </div>
</div> </div>
<div class="cert-kpi cert-kpi--amber"> <div class="cert-kpi cert-kpi--amber">
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon> <fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
<div> <div>
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div> <div class="cert-kpi-n">{{ count15H() | number }}</div>
<div class="cert-kpi-l">Senior citizen band (60+)</div> <div class="cert-kpi-l">Form 121 (60+)</div>
</div> </div>
</div> </div>
<div class="cert-kpi cert-kpi--green"> <div class="cert-kpi cert-kpi--green">
@@ -81,22 +47,6 @@
<div class="cert-kpi-l">Projected Interest</div> <div class="cert-kpi-l">Projected Interest</div>
</div> </div>
</div> </div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">TDS Saved</div>
</div>
</div>
@if (countIneligible() > 0) {
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
<div class="cert-kpi-l">Not Eligible</div>
</div>
</div>
}
</div> </div>
<!-- Filters --> <!-- Filters -->
@@ -110,11 +60,11 @@
</div> </div>
<div class="cert-filter"> <div class="cert-filter">
<label>Threshold Band</label> <label>Form Type</label>
<select [(ngModel)]="bandFilter"> <select [(ngModel)]="kindFilter">
<option value="">All bands</option> <option value="">All Form 121</option>
<option value="GENERAL">General (below 60)</option> <option value="15G">Form 121 (Below 60 years)</option>
<option value="SENIOR">Senior citizen (60+)</option> <option value="15H">Form 121 (Senior Citizen 60+)</option>
</select> </select>
</div> </div>
@@ -127,15 +77,7 @@
</select> </select>
</div> </div>
<div class="cert-filter"> @if (search || kindFilter || statusFilter) {
<label>Eligibility</label>
<select [(ngModel)]="ineligibleOnly">
<option [ngValue]="false">All customers</option>
<option [ngValue]="true">Not eligible only</option>
</select>
</div>
@if (search || bandFilter || statusFilter || ineligibleOnly) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button> <button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
} }
</div> </div>
@@ -147,92 +89,54 @@
<thead> <thead>
<tr> <tr>
<th>Customer · PAN</th> <th>Customer · PAN</th>
<th>Form · Band</th> <th>Form Type</th>
<th>Age</th> <th>Age</th>
<th>Branch</th> <th>Branch</th>
<th class="num">Est. Interest</th> <th class="num">Deposit Principal</th>
<th class="num">Threshold</th> <th class="num">Est. Interest ({{ records[0]?.fy }})</th>
<th class="num">TDS Payable</th>
<th class="num">TDS Saved</th>
<th>Settlement</th>
<th>Status</th> <th>Status</th>
<th class="col-actions">Actions</th> <th class="col-actions">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (row of filteredRows; track row.record.customerId) { @for (r of filteredRecords; track r.customerId) {
<tr [class.cert-row--warn]="!row.eligibility.eligible"> <tr>
<td> <td>
<div class="cert-cust-name">{{ row.record.customerName }}</div> <div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div> <div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div> <div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
</td> </td>
<td> <td>
<span <span
class="cert-pill" class="cert-pill"
[class.cert-pill--blue]="row.band === 'GENERAL'" [class.cert-pill--blue]="formKind(r) === '15G'"
[class.cert-pill--amber]="row.band === 'SENIOR'" [class.cert-pill--amber]="formKind(r) === '15H'"
> >
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }} Form 121 ({{ formKind(r) }})
</span> </span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
</td> </td>
<td class="small">{{ row.age }} yrs</td> <td class="small">{{ age(r.dob) }} yrs</td>
<td class="small">{{ row.record.branch }}</td> <td class="small">{{ r.branch }}</td>
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
<td class="num"> <td class="num">
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong> <strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
</td>
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
<td class="num">
@if (row.schedule.totalTds > 0) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="num">
@if (row.tdsSaved > 0) {
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="small">
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
</span>
<div class="small muted">product-driven</div>
</td> </td>
<td> <td>
@if (row.record.status === 'FILED') { @if (r.status === 'FILED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span> <span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
@if (row.record.filedOn) { @if (r.filedOn) {
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div> <div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
} }
} @else { } @else {
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span> <span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
} }
@if (!row.eligibility.eligible) {
<div class="cert-pill cert-pill--amber">
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
</div>
}
</td> </td>
<td class="col-actions"> <td class="col-actions">
<div class="cert-row-actions"> <div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn"
(click)="toggleExpanded(row)"
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
<button <button
type="button" type="button"
class="cert-icon-btn primary" class="cert-icon-btn primary"
(click)="viewCertificate(row)" (click)="viewCertificate(r)"
matTooltip="View / Print declaration" matTooltip="View / Print declaration"
> >
<fa-icon icon="eye"></fa-icon> <fa-icon icon="eye"></fa-icon>
@@ -240,7 +144,7 @@
<button <button
type="button" type="button"
class="cert-icon-btn" class="cert-icon-btn"
(click)="downloadCertificate(row)" (click)="downloadCertificate(r)"
matTooltip="Download PDF" matTooltip="Download PDF"
> >
<fa-icon icon="download"></fa-icon> <fa-icon icon="download"></fa-icon>
@@ -248,100 +152,10 @@
</div> </div>
</td> </td>
</tr> </tr>
@if (isExpanded(row)) {
<tr class="cert-row--detail">
<td colspan="11">
@if (!row.eligibility.eligible) {
<div class="cert-note cert-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Declaration cannot be accepted</strong>
<ul>
@for (reason of row.eligibility.reasons; track reason) {
<li>{{ reason }}</li>
}
</ul>
</div>
</div>
}
@for (note of row.eligibility.unassessed; track note) {
<div class="cert-note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@for (note of row.schedule.notes; track note) {
<div class="cert-note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
<table class="cert-table cert-table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="cert-pill cert-pill--amber">threshold crossed</span>
}
@if (line.declarationActive) {
<span class="cert-pill cert-pill--green">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
<tr>
<td class="small"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ row.schedule.totalInterest | number: '1.0-0' }}</strong>
</td>
<td></td>
<td></td>
<td class="num">
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
@if (row.schedule.shieldedInterest > 0) {
<div class="small muted">
₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
the declaration — reportable in the quarterly {{ statute.statement }} return.
</div>
}
</td>
</tr>
}
} }
@if (filteredRows.length === 0) { @if (filteredRecords.length === 0) {
<tr> <tr>
<td colspan="11" class="cert-empty"> <td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon> <fa-icon icon="info-circle"></fa-icon>
No declarations match the current filters. No declarations match the current filters.
</td> </td>
@@ -351,5 +165,4 @@
</table> </table>
</div> </div>
</div> </div>
}
</div> </div>
@@ -21,31 +21,9 @@ import {
} from '../certificate-preview-dialog/certificate-preview-dialog.component'; } from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadForm15ghPdf } from '../certificate-pdf'; import { downloadForm15ghPdf } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service'; import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component';
import { export type Form15Kind = '15G' | '15H';
Declaration,
DeclarationForm,
EligibilityResult,
FY_TAX_CONFIG,
Form15Kind,
TdsMode,
TdsSchedule,
ageInFy,
buildSchedule,
checkEligibility,
declarationFormFor,
formKindFor,
isSeniorInFy,
normaliseFy,
projectQuarterlyCredits,
tdsModeFor
} from './tds-15gh.compute';
export type { Form15Kind };
export type Form15Status = 'FILED' | 'PENDING'; export type Form15Status = 'FILED' | 'PENDING';
/** Which deduction trigger the payee falls under. Age-based, still. */
export type ThresholdBand = 'GENERAL' | 'SENIOR';
export interface Form15ghRecord { export interface Form15ghRecord {
customerId: string; customerId: string;
@@ -54,46 +32,18 @@ export interface Form15ghRecord {
customerAddress: string; customerAddress: string;
dob: string; dob: string;
branch: string; branch: string;
/** Aggregate interest the bank will pay this FY across the customer's time deposits. */ /** Aggregate interest the bank will pay this FY. */
estimatedInterest: number; estimatedInterest: number;
/** Customer's total estimated income for the FY, as declared by them. */ /** Customer's total estimated income for the FY. */
estimatedIncome: number; estimatedIncome: number;
/** Sum of FD / RD principals tied to the declaration. */ /** Sum of FD / RD principals tied to the declaration. */
depositPrincipal: number; depositPrincipal: number;
fy: string; fy: string;
status: Form15Status; status: Form15Status;
filedOn?: string; filedOn?: string;
/** /** "Yes / No" for: has any income tax been assessed for the customer
* "Yes / No" for: has any income tax been assessed for the customer under the under the IT Act in the past six years? */
* IT Act in the past six years?
*/
assessed: boolean; assessed: boolean;
/**
* The deposit product's interest-posting frequency. Tax falls due when interest
* is credited, so this — and not any customer preference — decides whether it
* settles quarterly or at year end.
*/
interestPostingPeriodType: string;
}
/** A register row with everything the engine derived for it, computed once. */
export interface Form15ghRow {
record: Form15ghRecord;
/** The form actually in force for the record's year — '121' from FY 2026-27. */
form: DeclarationForm;
/**
* The 15G/15H label. Only a form distinction up to FY 2025-26; from FY 2026-27
* the forms merged and this reflects the threshold band alone.
*/
kind: Form15Kind;
band: ThresholdBand;
age: number;
mode: TdsMode;
/** Schedule as things stand, declaration included where one is on record. */
schedule: TdsSchedule;
/** Tax the declaration kept out of the customer's pocket this year. */
tdsSaved: number;
eligibility: EligibilityResult;
} }
@Component({ @Component({
@@ -105,8 +55,7 @@ export interface Form15ghRow {
CommonModule, CommonModule,
FormsModule, FormsModule,
FaIconComponent, FaIconComponent,
MatTooltip, MatTooltip
RealForm15ghViewComponent
] ]
}) })
export class Form15ghComponent implements OnInit { export class Form15ghComponent implements OnInit {
@@ -114,89 +63,40 @@ export class Form15ghComponent implements OnInit {
private snack = inject(MatSnackBar); private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService); private dscSign = inject(DscSignService);
/** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */
viewMode: 'demo' | 'real' = 'demo';
// ── Filters ── // ── Filters ──
search = ''; search = '';
/** Which deduction trigger. Empty = both bands. */ /** Which form. Empty = both. */
bandFilter: '' | ThresholdBand = ''; kindFilter: '' | Form15Kind = '';
statusFilter: '' | Form15Status = ''; statusFilter: '' | Form15Status = '';
/** Show only declarations the branch cannot lawfully accept. */
ineligibleOnly = false;
/** Which row's quarter-wise breakdown is open. */ records: Form15ghRecord[] = [];
expandedId: string | null = null;
rows: Form15ghRow[] = [];
ngOnInit(): void { ngOnInit(): void {
// Derived once. The template reads these many times per change-detection this.records = seedForm15gh();
// pass, and re-running the schedules on every read is what made the other
// register screens stall.
this.rows = seedForm15gh().map((record) => this.buildRow(record));
} }
private buildRow(record: Form15ghRecord): Form15ghRow { /** Compute age in years from DoB. */
const ctx = { age(dob: string): number {
fy: record.fy, if (!dob) return 0;
dateOfBirth: record.dob, const today = new Date();
pan: record.customerPan, const birth = new Date(dob);
resident: true let age = today.getFullYear() - birth.getFullYear();
}; const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
const declaration: Declaration | null = return age;
record.status === 'FILED'
? {
kind: formKindFor(record.dob, record.fy),
// The register holds the filing date; a declaration operates from
// the day the branch accepted it.
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
fy: record.fy
}
: null;
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
// The register carries a projected annual figure rather than real postings,
// so spread it across the year to drive the schedule.
const credits = projectQuarterlyCredits(record.estimatedInterest, record.fy);
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
const withoutDeclaration = buildSchedule(mode, credits, { ...ctx, declaration: null });
return {
record,
form: declarationFormFor(record.fy, record.dob),
kind: formKindFor(record.dob, record.fy),
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
age: ageInFy(record.dob, record.fy),
mode,
schedule,
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
eligibility: checkEligibility(ctx, {
estimatedInterest: record.estimatedInterest,
estimatedIncome: record.estimatedIncome,
incomeOnRecord: true
})
};
} }
get filteredRows(): Form15ghRow[] { /** 15H once 60+, 15G otherwise. */
formKind(r: Form15ghRecord): Form15Kind {
return this.age(r.dob) >= 60 ? '15H' : '15G';
}
get filteredRecords(): Form15ghRecord[] {
const q = this.search.trim().toLowerCase(); const q = this.search.trim().toLowerCase();
return this.rows.filter((row) => { return this.records.filter((r) => {
if (this.bandFilter && row.band !== this.bandFilter) { if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
return false; if (this.statusFilter && r.status !== this.statusFilter) return false;
} if (!q) return true;
if (this.statusFilter && row.record.status !== this.statusFilter) {
return false;
}
if (this.ineligibleOnly && row.eligibility.eligible) {
return false;
}
if (!q) {
return true;
}
const r = row.record;
return ( return (
r.customerName.toLowerCase().includes(q) || r.customerName.toLowerCase().includes(q) ||
r.customerId.toLowerCase().includes(q) || r.customerId.toLowerCase().includes(q) ||
@@ -205,67 +105,32 @@ export class Form15ghComponent implements OnInit {
}); });
} }
countGeneralBand(): number { count15G(): number {
return this.rows.filter((r) => r.band === 'GENERAL').length; return this.records.filter((r) => this.formKind(r) === '15G').length;
} }
countSeniorBand(): number { count15H(): number {
return this.rows.filter((r) => r.band === 'SENIOR').length; return this.records.filter((r) => this.formKind(r) === '15H').length;
}
/**
* The declaration in force across the register, and the provision charging the
* tax. Both changed on 01 April 2026, so they are read from the config rather
* than written into the template.
*/
get statute(): { form: string; section: string; statement: string; frequency: string } {
const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27');
const cfg = FY_TAX_CONFIG[fy];
return {
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
section: cfg.interestTdsSection,
statement: cfg.tdsStatementForm,
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
} }
countFiled(): number { countFiled(): number {
return this.rows.filter((r) => r.record.status === 'FILED').length; return this.records.filter((r) => r.status === 'FILED').length;
}
/** Declarations on record that the branch could not lawfully have accepted. */
countIneligible(): number {
return this.rows.filter((r) => !r.eligibility.eligible).length;
} }
totalInterest(): number { totalInterest(): number {
return this.filteredRows.reduce((s, r) => s + r.record.estimatedInterest, 0); return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
}
/** Tax the declarations kept out of customers' pockets, across the filter. */
totalTdsSaved(): number {
return this.filteredRows.reduce((s, r) => s + r.tdsSaved, 0);
} }
clearFilters(): void { clearFilters(): void {
this.search = ''; this.search = '';
this.bandFilter = ''; this.kindFilter = '';
this.statusFilter = ''; this.statusFilter = '';
this.ineligibleOnly = false;
} }
toggleExpanded(row: Form15ghRow): void { viewCertificate(r: Form15ghRecord): void {
this.expandedId = this.expandedId === row.record.customerId ? null : row.record.customerId;
}
isExpanded(row: Form15ghRow): boolean {
return this.expandedId === row.record.customerId;
}
viewCertificate(row: Form15ghRow): void {
const data: CertificatePreviewDialogData = { const data: CertificatePreviewDialogData = {
kind: 'form15gh', kind: 'form15gh',
form15gh: { record: row.record, kind: row.kind, age: row.age } form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
}; };
this.dialog.open(CertificatePreviewDialogComponent, { this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px', width: '880px',
@@ -277,34 +142,20 @@ export class Form15ghComponent implements OnInit {
}); });
} }
downloadCertificate(row: Form15ghRow): void { downloadCertificate(r: Form15ghRecord): void {
const r = row.record; const kind = this.formKind(r);
// A declaration the branch cannot accept must not go out as a signed PDF.
if (!row.eligibility.eligible) {
this.snack.open(`Cannot issue: ${row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
return;
}
this.dscSign.promptThenDownload( this.dscSign.promptThenDownload(
{ {
title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`, title: `Form 121 (${kind}) Declaration`,
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`, reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf` filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
}, },
(dsc) => downloadForm15ghPdf(r, row.kind, dsc) (dsc) => downloadForm15ghPdf(r, kind, dsc)
); );
} }
toggleViewMode(): void {
this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo';
}
} }
// ───────────────────────── Mock seed (top-level export) ───────────────────── // ───────────────────── Mock seed (top-level export) ─────────────────────
/**
* Two of these customers are deliberately ineligible — their estimated income
* leaves a tax liability, so a Form 121 declaration cannot be accepted from them.
* That gives the register's eligibility warnings something to show.
*/
export function seedForm15gh(): Form15ghRecord[] { export function seedForm15gh(): Form15ghRecord[] {
return [ return [
{ {
@@ -314,109 +165,86 @@ export function seedForm15gh(): Form15ghRecord[] {
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
dob: '1985-02-14', dob: '1985-02-14',
branch: 'Mumbai — Andheri (E)', branch: 'Mumbai — Andheri (E)',
estimatedInterest: 38_500, estimatedInterest: 38500,
// Over the basic exemption limit — not eligible for 15G. estimatedIncome: 850000,
estimatedIncome: 850_000, depositPrincipal: 500000,
depositPrincipal: 500_000, fy: 'FY 2025-26',
fy: 'FY 2026-27', status: 'FILED',
status: 'PENDING', filedOn: '2025-04-10',
assessed: true, assessed: true
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
}, },
{ {
customerId: 'CUST556677', customerId: 'CUST556677',
customerName: 'Karthik Rao', customerName: 'Karthik Rao',
customerPan: 'PQRPR2233Z', customerPan: 'PQRSR2233Z',
customerAddress: '24, MG Road, Bengaluru 560001', customerAddress: '24, MG Road, Bengaluru 560001',
dob: '1972-09-04', dob: '1972-09-04',
branch: 'Bengaluru — MG Road', branch: 'Bengaluru — MG Road',
estimatedInterest: 52_800, estimatedInterest: 52800,
estimatedIncome: 320_000, estimatedIncome: 320000,
depositPrincipal: 700_000, depositPrincipal: 700000,
fy: 'FY 2026-27', fy: 'FY 2025-26',
status: 'PENDING', status: 'PENDING',
assessed: false, assessed: false
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
}, },
{ {
customerId: 'CUST667788', customerId: 'CUST667788',
customerName: 'Lakshmi Naidu', customerName: 'Lakshmi Naidu',
customerPan: 'XYZPN7788Q', customerPan: 'XYZAN7788Q',
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
dob: '1958-06-22', dob: '1958-06-22',
branch: 'Hyderabad — Banjara Hills', branch: 'Hyderabad — Banjara Hills',
estimatedInterest: 88_200, estimatedInterest: 88200,
estimatedIncome: 410_000, estimatedIncome: 410000,
depositPrincipal: 1_200_000, depositPrincipal: 1200000,
fy: 'FY 2026-27', fy: 'FY 2025-26',
status: 'FILED', status: 'FILED',
filedOn: '2026-04-15', filedOn: '2025-04-15',
assessed: true, assessed: true
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
}, },
{ {
customerId: 'CUST334455', customerId: 'CUST334455',
customerName: 'Anita Verma', customerName: 'Anita Verma',
customerPan: 'EFGPV5678A', customerPan: 'EFGHV5678A',
customerAddress: 'B-12, Connaught Pl, New Delhi 110001', customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
dob: '1962-11-30', dob: '1962-11-30',
branch: 'New Delhi — Connaught Place', branch: 'New Delhi — Connaught Place',
estimatedInterest: 64_500, estimatedInterest: 64500,
estimatedIncome: 280_000, estimatedIncome: 280000,
depositPrincipal: 900_000, depositPrincipal: 900000,
fy: 'FY 2026-27', fy: 'FY 2025-26',
status: 'FILED', status: 'FILED',
filedOn: '2026-04-05', filedOn: '2025-04-05',
assessed: false, assessed: false
interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual'
}, },
{ {
customerId: 'CUST990011', customerId: 'CUST990011',
customerName: 'Arjun Banerjee', customerName: 'Arjun Banerjee',
customerPan: 'TUVPB4455F', customerPan: 'TUVWB4455F',
customerAddress: '42, Park Street, Kolkata 700016', customerAddress: '42, Park Street, Kolkata 700016',
dob: '1991-07-19', dob: '1991-07-19',
branch: 'Kolkata — Park Street', branch: 'Kolkata — Park Street',
estimatedInterest: 14_200, estimatedInterest: 14200,
// Over the basic exemption limit — not eligible for 15G. estimatedIncome: 620000,
estimatedIncome: 620_000, depositPrincipal: 200000,
depositPrincipal: 200_000, fy: 'FY 2025-26',
fy: 'FY 2026-27',
status: 'PENDING', status: 'PENDING',
assessed: true, assessed: true
interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly'
}, },
{ {
customerId: 'CUST330044', customerId: 'CUST330044',
customerName: 'Madhavi Singh', customerName: 'Madhavi Singh',
customerPan: 'TUVPS3344K', customerPan: 'TUVWS3344K',
customerAddress: 'D-5, Greater Kailash, New Delhi 110048', customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
dob: '1955-03-08', dob: '1955-03-08',
branch: 'New Delhi — Connaught Place', branch: 'New Delhi — Connaught Place',
// A 15H declarant faces no ceiling on interest — only a nil liability. estimatedInterest: 145000,
estimatedInterest: 145_000, estimatedIncome: 320000,
estimatedIncome: 320_000, depositPrincipal: 2000000,
depositPrincipal: 2_000_000, fy: 'FY 2025-26',
fy: 'FY 2026-27',
status: 'FILED', status: 'FILED',
filedOn: '2026-04-02', filedOn: '2025-04-02',
assessed: false, assessed: false
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
},
{
customerId: 'CUST778899',
customerName: 'Vikram Malhotra',
customerPan: '',
customerAddress: 'Flat 405, Green Glen Layout, Outer Ring Road, Bengaluru 560103',
dob: '1980-05-15',
branch: 'Bengaluru — MG Road',
estimatedInterest: 60_000,
estimatedIncome: 350_000,
depositPrincipal: 800_000,
fy: 'FY 2026-27',
status: 'PENDING',
assessed: true,
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
} }
]; ];
} }
@@ -1,329 +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/.
-->
@if (loading) {
<div class="cert-empty">
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px;"></fa-icon>
<div>Loading live declarations and accounts from Fineract…</div>
</div>
} @else if (loadError) {
<div class="cert-empty" style="color: #ef4444;">
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px;"></fa-icon>
<div>Could not load real data from Fineract. Check connection.</div>
</div>
} @else {
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
<div class="cert-kpi-l">General band (under 60)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
<div class="cert-kpi-l">Senior citizen band (60+)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
<div class="cert-kpi-l">Filed this FY</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Projected Interest</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">TDS Saved</div>
</div>
</div>
@if (countIneligible() > 0) {
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
<div class="cert-kpi-l">Not Eligible</div>
</div>
</div>
}
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Financial Year</label>
<select [(ngModel)]="selectedFy" (ngModelChange)="loadRealData()">
@for (y of availableFys; track y) {
<option [value]="y">{{ y }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Customer / Customer ID / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="Search live data..." />
</div>
</div>
<div class="cert-filter">
<label>Threshold Band</label>
<select [(ngModel)]="bandFilter">
<option value="">All bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="FILED">Filed</option>
<option value="PENDING">Pending</option>
</select>
</div>
<div class="cert-filter">
<label>Eligibility</label>
<select [(ngModel)]="ineligibleOnly">
<option [ngValue]="false">All customers</option>
<option [ngValue]="true">Not eligible only</option>
</select>
</div>
@if (search || bandFilter || statusFilter || ineligibleOnly) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Form · Band</th>
<th>Age</th>
<th>Branch</th>
<th class="num">Est. Interest</th>
<th class="num">Threshold</th>
<th class="num">TDS Payable</th>
<th class="num">TDS Saved</th>
<th>Settlement</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.row.record.customerId) {
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
<td>
<div class="cert-cust-name">{{ row.row.record.customerName }}</div>
<div class="mono small muted">{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}</div>
<div class="small muted">DOB {{ row.row.record.dob | date: 'd MMM y' }}</div>
</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="row.row.band === 'GENERAL'"
[class.cert-pill--amber]="row.row.band === 'SENIOR'"
>
{{ row.row.form === '121' ? 'Form 121' : 'Form ' + row.row.form }}
</span>
<div class="small muted">{{ row.row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
</td>
<td class="small">{{ row.row.age }} yrs</td>
<td class="small">{{ row.row.record.branch || '—' }}</td>
<td class="num">
<strong class="cert-interest">₹{{ row.row.record.estimatedInterest | number: '1.0-0' }}</strong>
</td>
<td class="num small muted">₹{{ row.row.schedule.threshold | number: '1.0-0' }}</td>
<td class="num">
@if (row.row.schedule.totalTds > 0) {
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="num">
@if (row.row.tdsSaved > 0) {
<strong class="cert-interest">₹{{ row.row.tdsSaved | number: '1.0-0' }}</strong>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="small">
<span class="cert-pill" [class.cert-pill--blue]="row.row.mode === 'QUARTERLY'">
{{ row.row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
</span>
<div class="small muted">product-driven</div>
</td>
<td>
@if (row.row.record.status === 'FILED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
@if (row.row.record.filedOn) {
<div class="mono small muted">{{ row.row.record.filedOn | date: 'd MMM y' }}</div>
}
} @else {
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
}
@if (!row.row.eligibility.eligible) {
<div class="cert-pill cert-pill--amber">
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
</div>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn"
(click)="toggleExpanded(row)"
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(row)"
matTooltip="View / Print declaration"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(row)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
@if (isExpanded(row)) {
<tr class="cert-row--detail">
<td colspan="11">
@if (!row.row.eligibility.eligible) {
<div class="cert-note cert-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Declaration cannot be accepted</strong>
<ul>
@for (reason of row.row.eligibility.reasons; track reason) {
<li>{{ reason }}</li>
}
</ul>
</div>
</div>
}
@for (note of row.row.eligibility.unassessed; track note) {
<div class="cert-note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@for (note of row.row.schedule.notes; track note) {
<div class="cert-note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
<table class="cert-table cert-table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.row.schedule.lines; track line.quarter) {
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="cert-pill cert-pill--amber">threshold crossed</span>
}
@if (line.declarationActive) {
<span class="cert-pill cert-pill--green">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="small muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
<tr>
<td class="small"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ row.row.schedule.totalInterest | number: '1.0-0' }}</strong>
</td>
<td></td>
<td></td>
<td class="num">
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
@if (row.row.schedule.shieldedInterest > 0) {
<div class="small muted">
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
the declaration — reportable in the quarterly {{ statute.statement }} return.
</div>
}
</td>
</tr>
}
}
@if (filteredRows.length === 0) {
<tr>
<td colspan="11" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No active clients match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@@ -1,502 +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, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { forkJoin, of, Observable, Subject } from 'rxjs';
import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from '../../../../clients/clients.service';
import { SavingsService } from '../../../../savings/savings.service';
import { FixedDepositsService } from '../../../../deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from '../../../../deposits/recurring-deposits/recurring-deposits.service';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadForm15ghPdf } from '../../certificate-pdf';
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
import {
Declaration,
DeclarationForm,
EligibilityResult,
FY_TAX_CONFIG,
Form15Kind,
TdsMode,
TdsSchedule,
ageInFy,
buildSchedule,
checkEligibility,
collectTdsCredits,
declarationFormFor,
formKindFor,
isSeniorInFy,
normaliseFy,
projectQuarterlyCredits,
tdsDeducteeOf,
tdsModeFor,
isTdsApplicable,
InterestCredit,
TdsAccountInput,
fyOf
} from '../tds-15gh.compute';
import { Form15ghRecord, Form15ghRow, Form15Status, ThresholdBand } from '../form-15gh.component';
export interface RealForm15ghRow {
row: Form15ghRow;
clientId: string;
}
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
function toIso(value: unknown): string | null {
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d] = value as number[];
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
}
if (typeof value === 'string' && value.trim()) {
const parsed = new Date(value);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 10);
}
}
return null;
}
@Component({
selector: 'mifosx-real-form15gh-view',
templateUrl: './real-form15gh-view.component.html',
styleUrls: ['../../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class RealForm15ghViewComponent implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private dscSign = inject(DscSignService);
private destroy$ = new Subject<void>();
loading = true;
loadError = false;
rows: RealForm15ghRow[] = [];
// ── Filters ──
search = '';
bandFilter: '' | ThresholdBand = '';
statusFilter: '' | Form15Status = '';
ineligibleOnly = false;
/** Which row's quarter-wise breakdown is open. */
expandedId: string | null = null;
selectedFy = 'FY 2026-27';
// Per-client caches
private clientDataCache = new Map<string, Observable<any>>();
private clientKycCache = new Map<string, Observable<any>>();
private clientAccountsCache = new Map<string, Observable<any>>();
readonly availableFys = ['FY 2026-27', 'FY 2025-26'];
ngOnInit(): void {
this.loadRealData();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadRealData(): void {
this.loading = true;
this.loadError = false;
this.clientDataCache.clear();
this.clientKycCache.clear();
this.clientAccountsCache.clear();
// Fetch up to 100 active individual clients.
this.clientsService
.searchByText('', 0, 100, '', '', true, 'INDIVIDUAL', true)
.pipe(
takeUntil(this.destroy$),
map((page: any) => page?.content || page?.pageItems || (Array.isArray(page) ? page : [])),
switchMap((clients: any[]) => {
if (!clients.length) {
return of([] as RealForm15ghRow[]);
}
return forkJoin(clients.map((c) => this.buildRealRow(c))).pipe(
map((rows) => rows.filter((r): r is RealForm15ghRow => r !== null))
);
}),
catchError(() => {
this.loadError = true;
return of([] as RealForm15ghRow[]);
})
)
.subscribe((rows) => {
this.rows = rows;
this.loading = false;
});
}
private buildRealRow(clientSummary: any): Observable<RealForm15ghRow | null> {
const clientId = clientSummary?.id != null ? String(clientSummary.id) : null;
if (!clientId) {
return of(null);
}
return forkJoin({
client: this.getClientDataCached(clientId),
kyc: this.getClientKycCached(clientId),
accountsSummary: this.getClientAccountsCached(clientId)
}).pipe(
switchMap(({ client, kyc, accountsSummary }) => {
const activeSavings = (accountsSummary?.savingsAccounts ?? []).filter(
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
);
if (!activeSavings.length) {
// If no accounts, we can't calculate interest. But we can still build an empty row if a declaration is filed
return of({ client, kyc, accountDetailsList: [] });
}
return forkJoin(activeSavings.map((a: any) => this.fetchAccountDetails(a))).pipe(
map((detailsList) => ({
client,
kyc,
accountDetailsList: (detailsList as any[]).filter(Boolean)
}))
);
}),
map(({ client, kyc, accountDetailsList }) => {
if (!client) {
return null;
}
const record = this.mapToRecord(client, kyc, accountDetailsList);
if (!record) {
return null;
}
const row = this.computeRow(record, accountDetailsList);
return {
row,
clientId
};
}),
catchError(() => of(null))
);
}
private getClientDataCached(clientId: string): Observable<any> {
if (!this.clientDataCache.has(clientId)) {
this.clientDataCache.set(
clientId,
this.clientsService.getClientData(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientDataCache.get(clientId)!;
}
private getClientKycCached(clientId: string): Observable<any> {
if (!this.clientKycCache.has(clientId)) {
this.clientKycCache.set(
clientId,
this.clientsService.getClientKyc(clientId, true).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientKycCache.get(clientId)!;
}
private getClientAccountsCached(clientId: string): Observable<any> {
if (!this.clientAccountsCache.has(clientId)) {
this.clientAccountsCache.set(
clientId,
this.clientsService.getClientAccountData(clientId).pipe(
catchError(() => of(null)),
shareReplay(1)
)
);
}
return this.clientAccountsCache.get(clientId)!;
}
private fetchAccountDetails(accountSummary: any): Observable<any> {
const id = String(accountSummary.id);
const code = accountSummary.depositType?.code ?? '';
let call: Observable<any>;
if (code.includes('fixedDeposit')) {
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
} else if (code.includes('recurringDeposit')) {
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
} else {
call = this.savingsService.getSavingsAccountData(id);
}
return call.pipe(catchError(() => of(null)));
}
private mapToRecord(client: any, kyc: any, detailsList: any[]): Form15ghRecord | null {
const dob = toIso(client?.dateOfBirth) ?? '';
if (!dob) {
return null;
}
const fy = normaliseFy(this.selectedFy);
const hasDeclaration = !!kyc?.form15Applicable && normaliseFy(kyc?.form15Fy ?? '') === fy;
const status: Form15Status = hasDeclaration ? 'FILED' : 'PENDING';
// Sum estimated time deposit interest for the selected year
let estimatedInterest = 0;
let depositPrincipal = 0;
for (const d of detailsList) {
const code = d.depositType?.code ?? '';
const isTimeDeposit = code.includes('fixedDeposit') || code.includes('recurringDeposit');
if (isTimeDeposit) {
depositPrincipal += d.depositAmount ?? d.nominalAnnualInterestRate ?? 0;
// Sum interest transactions in the selected FY
for (const t of d.transactions ?? []) {
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
continue;
}
const iso = toIso(t.date);
if (iso && fyOf(iso) === fy) {
estimatedInterest += t.amount ?? 0;
}
}
}
}
// Default to a small placeholder principal/interest if the live accounts have no transaction history yet,
// so the table is not completely blank of figures.
if (estimatedInterest === 0 && depositPrincipal > 0) {
estimatedInterest = Math.round(depositPrincipal * 0.07);
}
return {
customerId: client.accountNo || String(client.id),
customerName: client.displayName || '',
customerPan: (kyc?.panNumber ?? '').toUpperCase(),
customerAddress: client.officeName || '',
dob,
branch: client.officeName || '',
estimatedInterest,
estimatedIncome: kyc?.estimatedIncome ?? 0,
depositPrincipal,
fy: this.selectedFy,
status,
filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined,
assessed: true,
interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly'
};
}
private computeRow(record: Form15ghRecord, detailsList: any[]): Form15ghRow {
const ctx = {
fy: record.fy,
dateOfBirth: record.dob,
pan: record.customerPan,
resident: true
};
const declaration: Declaration | null =
record.status === 'FILED'
? {
kind: formKindFor(record.dob, record.fy),
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
fy: record.fy
}
: null;
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
// Map the actual account transactions to InterestCredit inputs for the schedule calculation
const accountInputs: TdsAccountInput[] = detailsList.map((d) => {
const code = d.depositType?.code ?? '';
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
const ref = {
id: d.id,
accountNo: d.accountNo ?? String(d.id),
depositTypeCode: code,
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
clientId: d.clientId ?? Number(record.customerId),
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
interestPostingPeriodType: d.interestPostingPeriodType ?? null
};
const credits: InterestCredit[] = [];
for (const t of d.transactions ?? []) {
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
continue;
}
const iso = toIso(t.date);
if (iso) {
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
}
}
return {
...ref,
credits
};
});
const collectedCredits = collectTdsCredits(Number(record.customerId), accountInputs, record.fy);
// If no real transactions are recorded yet, fall back to spreading the estimated interest
const creditsToUse = collectedCredits.length
? collectedCredits
: projectQuarterlyCredits(record.estimatedInterest, record.fy);
const schedule = buildSchedule(mode, creditsToUse, { ...ctx, declaration });
const withoutDeclaration = buildSchedule(mode, creditsToUse, { ...ctx, declaration: null });
return {
record,
form: declarationFormFor(record.fy, record.dob),
kind: formKindFor(record.dob, record.fy),
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
age: ageInFy(record.dob, record.fy),
mode,
schedule,
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
eligibility: checkEligibility(ctx, {
estimatedInterest: record.estimatedInterest,
estimatedIncome: record.estimatedIncome || 250000,
incomeOnRecord: true
})
};
}
get filteredRows(): RealForm15ghRow[] {
const q = this.search.trim().toLowerCase();
return this.rows.filter(({ row }) => {
if (this.bandFilter && row.band !== this.bandFilter) {
return false;
}
if (this.statusFilter && row.record.status !== this.statusFilter) {
return false;
}
if (this.ineligibleOnly && row.eligibility.eligible) {
return false;
}
if (!q) {
return true;
}
const r = row.record;
return (
r.customerName.toLowerCase().includes(q) ||
r.customerId.toLowerCase().includes(q) ||
r.customerPan.toLowerCase().includes(q)
);
});
}
countGeneralBand(): number {
return this.rows.filter((r) => r.row.band === 'GENERAL').length;
}
countSeniorBand(): number {
return this.rows.filter((r) => r.row.band === 'SENIOR').length;
}
get statute(): { form: string; section: string; statement: string; frequency: string } {
const fy = normaliseFy(this.selectedFy);
const cfg = FY_TAX_CONFIG[fy];
return {
form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
countFiled(): number {
return this.rows.filter((r) => r.row.record.status === 'FILED').length;
}
countIneligible(): number {
return this.rows.filter((r) => !r.row.eligibility.eligible).length;
}
totalInterest(): number {
return this.filteredRows.reduce((s, r) => s + r.row.record.estimatedInterest, 0);
}
totalTdsSaved(): number {
return this.filteredRows.reduce((s, r) => s + r.row.tdsSaved, 0);
}
clearFilters(): void {
this.search = '';
this.bandFilter = '';
this.statusFilter = '';
this.ineligibleOnly = false;
}
toggleExpanded(row: RealForm15ghRow): void {
this.expandedId = this.expandedId === row.row.record.customerId ? null : row.row.record.customerId;
}
isExpanded(row: RealForm15ghRow): boolean {
return this.expandedId === row.row.record.customerId;
}
viewCertificate(row: RealForm15ghRow): void {
const data: CertificatePreviewDialogData = {
kind: 'form15gh',
form15gh: { record: row.row.record, kind: row.row.kind, age: row.row.age }
};
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(row: RealForm15ghRow): void {
const r = row.row.record;
if (!row.row.eligibility.eligible) {
this.snack.open(`Cannot issue: ${row.row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
return;
}
this.dscSign.promptThenDownload(
{
title: `${row.row.form === '121' ? 'Form 121' : `Form ${row.row.form}`} Declaration`,
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
},
(dsc) => downloadForm15ghPdf(r, row.row.kind, dsc)
);
}
}
@@ -1,766 +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 {
FY_TAX_CONFIG,
InterestCredit,
TdsAccountInput,
TdsContext,
ageInFy,
declarationFormFor,
annualSchedule,
bucketByQuarter,
checkEligibility,
collectTdsCredits,
cumulativeLiability,
formKindFor,
fyOf,
isTdsApplicable,
isWellFormedPan,
lateDeductionInterest,
monthsOrPartBetween,
projectQuarterlyCredits,
quarterlySchedule,
reconcile,
tdsDeducteeOf,
tdsModeFor
} from './tds-15gh.compute';
const FY = 'FY 2026-27';
/** Under 60 for FY 2026-27, valid individual PAN. */
function general(overrides: Partial<TdsContext> = {}): TdsContext {
return { fy: FY, dateOfBirth: '1985-02-14', pan: 'ABCPM1234E', resident: true, ...overrides };
}
/** 60+ for FY 2026-27. */
function senior(overrides: Partial<TdsContext> = {}): TdsContext {
return { fy: FY, dateOfBirth: '1958-06-22', pan: 'ABCPM1234E', resident: true, ...overrides };
}
/** Four dated credits, one per quarter. */
function credits(q1: number, q2: number, q3: number, q4: number): InterestCredit[] {
return [
{ date: '2026-06-30', amount: q1 },
{ date: '2026-09-30', amount: q2 },
{ date: '2026-12-31', amount: q3 },
{ date: '2027-03-31', amount: q4 }
];
}
describe('194A threshold behaviour', () => {
it('deducts nothing while the aggregate stays under the trigger', () => {
expect(quarterlySchedule(credits(12_000, 12_000, 12_000, 12_000), general()).totalTds).toBe(0);
});
it('deducts nothing when the aggregate lands exactly on the trigger', () => {
// The section bites only where the amount *exceeds* the limit.
expect(quarterlySchedule(credits(12_500, 12_500, 12_500, 12_500), general()).totalTds).toBe(0);
});
it('treats the threshold as a trigger, not an allowance', () => {
// ₹52,000 of interest: 10% of the whole ₹52,000, not 10% of the ₹2,000 above
// the ₹50,000 line.
const s = annualSchedule(credits(13_000, 13_000, 13_000, 13_000), general());
expect(s.totalTds).toBe(5_200);
expect(s.totalTds).not.toBe(200);
});
it('applies the senior citizen trigger of ₹1,00,000', () => {
const c = credits(20_000, 20_000, 20_000, 20_000); // ₹80,000
expect(quarterlySchedule(c, general()).totalTds).toBe(8_000);
expect(quarterlySchedule(c, senior()).totalTds).toBe(0);
});
it('falls back to the 206AA rate of 20% where PAN is not on record', () => {
const c = credits(25_000, 25_000, 25_000, 25_000); // ₹1,00,000
expect(annualSchedule(c, general()).totalTds).toBe(10_000);
expect(annualSchedule(c, general({ pan: '' })).totalTds).toBe(20_000);
});
});
describe('the quarterly schedule catches up in the quarter the trigger is crossed', () => {
it('loads the whole year-to-date liability into that quarter', () => {
// ₹20,000 a quarter. The aggregate crosses ₹50,000 during Q3.
const s = quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general());
expect(s.lines.map((l) => l.tds)).toEqual([
0,
0,
6_000,
2_000
]);
expect(s.lines[2].thresholdCrossedHere).toBe(true);
// Q3 deducts ₹6,000 against ₹20,000 of that quarter's own interest. That is
// correct: the earlier quarters became liable the moment the line was
// crossed, and the branch collects the arrears now.
expect(s.totalTds).toBe(8_000);
});
it('never deducts a negative amount on a rising book', () => {
const s = quarterlySchedule(credits(5_000, 60_000, 1_000, 400), general());
for (const line of s.lines) {
expect(line.tds).toBeGreaterThanOrEqual(0);
}
});
});
describe('quarterly and annual settlement agree to the rupee', () => {
const distributions: [number, number, number, number][] = [
[
12_000,
12_000,
12_000,
12_000
],
[
20_000,
20_000,
20_000,
20_000
],
[
60_000,
0,
0,
0
],
[
0,
0,
0,
75_000
],
[
49_999,
1,
0,
0
],
[
13_333,
13_333,
13_333,
13_334
],
[
1,
2,
3,
99_999
],
[
250_000,
250_000,
250_000,
250_000
]
];
it.each(distributions)('holds for %i / %i / %i / %i', (q1, q2, q3, q4) => {
const c = credits(q1, q2, q3, q4);
for (const ctx of [
general(),
senior(),
general({ pan: '' })
]) {
const q = quarterlySchedule(c, ctx);
const a = annualSchedule(c, ctx);
expect(q.totalTds).toBe(a.totalTds);
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(a.totalTds);
}
});
it('holds under section 288B rounding to the nearest ₹10', () => {
const ctx = general({ config: { roundTo: 10 } });
const c = credits(13_333, 13_333, 13_333, 13_334);
expect(quarterlySchedule(c, ctx).totalTds).toBe(annualSchedule(c, ctx).totalTds);
expect(annualSchedule(c, ctx).totalTds % 10).toBe(0);
});
/**
* Property test. Rounding drift only shows up on awkward numbers, so sweep a
* deterministic spread of them rather than trusting hand-picked cases.
*/
it('holds across 2000 pseudo-random interest distributions', () => {
let seed = 20260801;
const next = (max: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return Math.floor((seed / 4294967296) * max);
};
for (let i = 0; i < 2000; i++) {
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
const ctx = general({ config: { roundTo: i % 2 === 0 ? 1 : 10 } });
const q = quarterlySchedule(c, ctx);
const a = annualSchedule(c, ctx);
if (q.totalTds !== a.totalTds) {
throw new Error(
`Mismatch on ${c.map((x) => x.amount).join('/')}: quarterly ${q.totalTds} vs annual ${a.totalTds}`
);
}
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(q.totalTds);
}
});
it('holds across 2000 distributions with a declaration at every acceptance point', () => {
let seed = 19980731;
const next = (max: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return Math.floor((seed / 4294967296) * max);
};
const dates = [
'2026-04-01',
'2026-05-15',
'2026-08-01',
'2026-10-01',
'2027-01-10',
'2027-03-31'
];
for (let i = 0; i < 2000; i++) {
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
const ctx = general({
declaration: { kind: '15G', acceptedOn: dates[i % dates.length], fy: '2026-27' },
config: { roundTo: i % 2 === 0 ? 1 : 10 }
});
const q = quarterlySchedule(c, ctx);
const a = annualSchedule(c, ctx);
if (q.totalTds !== a.totalTds) {
throw new Error(
`Mismatch on ${c.map((x) => x.amount).join('/')} declared ${dates[i % dates.length]}: ` +
`quarterly ${q.totalTds} vs annual ${a.totalTds}`
);
}
}
});
it('adjusts within the year when interest is reversed', () => {
// Q2 took the aggregate to ₹80,000 and ₹8,000 was deducted. The reversal
// brings the year back to ₹50,000, which does not exceed the trigger at all,
// so the whole ₹8,000 is given back in Q3.
const c = credits(40_000, 40_000, -30_000, 0);
const q = quarterlySchedule(c, general());
expect(q.lines.map((l) => l.tds)).toEqual([
0,
8_000,
-8_000,
0
]);
expect(q.totalTds).toBe(0);
expect(annualSchedule(c, general()).totalTds).toBe(0);
});
});
describe('a Form 121 declaration', () => {
const fromApril = general({
declaration: { kind: '15G', acceptedOn: '2026-04-01', fy: '2026-27', uin: 'U0001' }
});
const fromOctober = general({
declaration: { kind: '15G', acceptedOn: '2026-10-01', fy: '2026-27' }
});
it('stops deduction for the whole year when on record from April', () => {
const c = credits(30_000, 30_000, 30_000, 30_000);
expect(quarterlySchedule(c, fromApril).totalTds).toBe(0);
expect(annualSchedule(c, fromApril).totalTds).toBe(0);
expect(reconcile(c, fromApril).matched).toBe(true);
});
it('shields only the interest credited on or after it was accepted', () => {
const s = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
expect(s.liableInterest).toBe(60_000);
expect(s.shieldedInterest).toBe(60_000);
});
it('does not claw back tax already remitted before it arrived', () => {
const q = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
expect(q.lines.map((l) => l.tds)).toEqual([
0,
6_000,
0,
0
]);
expect(q.totalTds).toBe(6_000);
expect(q.notes[0]).toContain('cannot reverse it');
});
it('leaves the two modes in agreement even when taken mid-year', () => {
// A declaration is prospective, so the year-end figure still sees the
// pre-declaration interest as liable.
const r = reconcile(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
expect(r.quarterlyTotal).toBe(6_000);
expect(r.annualTotal).toBe(6_000);
expect(r.matched).toBe(true);
});
it('reports the divergence when liability falls after deductions were frozen', () => {
// Q1 crossed the trigger and ₹6,000 went to the Government. A reversal in
// Q3, after the declaration was on record, drags the year back under
// ₹50,000 — the liability is now nil but the branch cannot refund.
const c: InterestCredit[] = [
{ date: '2026-06-30', amount: 60_000 },
{ date: '2026-12-31', amount: -15_000 }
];
const r = reconcile(c, fromOctober);
expect(r.quarterlyTotal).toBe(6_000);
expect(r.annualTotal).toBe(0);
expect(r.matched).toBe(false);
expect(quarterlySchedule(c, fromOctober).lines[2].tds).toBe(0);
});
});
describe('age is measured against the financial year, not today', () => {
it('gives the senior threshold to a customer who turns 60 in March', () => {
expect(ageInFy('1967-03-20', FY)).toBe(60);
expect(formKindFor('1967-03-20', FY)).toBe('15H');
const ctx = general({ dateOfBirth: '1967-03-20' });
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), ctx).threshold).toBe(100_000);
});
it('keeps the general threshold for a customer who turns 60 the next April', () => {
expect(ageInFy('1967-04-20', FY)).toBe(59);
expect(formKindFor('1967-04-20', FY)).toBe('15G');
});
});
describe('savings interest is outside section 194A', () => {
it.each([
[
'depositAccountType.fixedDeposit',
true
],
[
'depositAccountType.recurringDeposit',
true
],
[
'depositAccountType.savingsDeposit',
false
],
[
'depositAccountType.currentDeposit',
false
],
[
'',
false
]
])('%s -> %s', (code, expected) => {
expect(isTdsApplicable({ depositTypeCode: code })).toBe(expected);
});
it('keeps savings interest out of the aggregate entirely', () => {
const accounts: TdsAccountInput[] = [
{
accountNo: 'FD001',
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'SINGLE',
clientId: 1,
credits: credits(10_000, 10_000, 10_000, 10_000)
},
{
accountNo: 'SB001',
depositTypeCode: 'depositAccountType.savingsDeposit',
accountStatus: 'SINGLE',
clientId: 1,
credits: credits(5_000, 5_000, 5_000, 5_000)
}
];
const collected = collectTdsCredits(1, accounts, FY);
// ₹40,000 of FD interest, not ₹60,000 — so the trigger is never crossed and
// nothing is deducted. Folding the savings interest in would have crossed it.
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(40_000);
expect(quarterlySchedule(collected, general()).totalTds).toBe(0);
});
});
describe('whose PAN the tax is deducted against', () => {
it('uses the account holder for a single-name account', () => {
expect(tdsDeducteeOf({ accountStatus: 'SINGLE', clientId: 7 })).toBe(7);
expect(tdsDeducteeOf({ accountStatus: 'INDIVIDUAL_MALE', clientId: 7 })).toBe(7);
});
it('uses the first-named holder on a joint account', () => {
expect(
tdsDeducteeOf({
accountStatus: 'JOINT',
primaryClientId: 7,
jointHolders: [{ clientId: 9 }]
})
).toBe(7);
});
it('uses the guardian on a minor account', () => {
expect(tdsDeducteeOf({ accountStatus: 'MINOR', clientId: 11, guardianClientId: 4 })).toBe(4);
});
it('does not count a joint deposit twice across the two holders', () => {
const jointFd: TdsAccountInput = {
accountNo: 'FD-JOINT',
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'JOINT',
primaryClientId: 7,
jointHolders: [{ clientId: 9 }],
credits: credits(15_000, 15_000, 15_000, 15_000) // ₹60,000
};
// Primary holder carries the whole ₹60,000 and the whole ₹6,000 of tax.
const primary = collectTdsCredits(7, [jointFd], FY);
expect(primary.reduce((s, c) => s + c.amount, 0)).toBe(60_000);
expect(quarterlySchedule(primary, general()).totalTds).toBe(6_000);
// The second holder carries none of it — no double deduction on one deposit.
const secondHolder = collectTdsCredits(9, [jointFd], FY);
expect(secondHolder).toEqual([]);
expect(quarterlySchedule(secondHolder, general()).totalTds).toBe(0);
});
it('ignores any share percentage when attributing interest', () => {
// A 50/50 joint account still reports the full amount against the primary
// holder; splitting would produce two deductees for one deposit.
const jointFd: TdsAccountInput = {
accountNo: 'FD-JOINT',
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'JOINT',
primaryClientId: 7,
jointHolders: [{ clientId: 9 }],
credits: credits(15_000, 15_000, 15_000, 15_000)
};
expect(collectTdsCredits(7, [jointFd], FY).reduce((s, c) => s + c.amount, 0)).toBe(60_000);
});
it('aggregates a customer across every branch', () => {
// Three deposits in three branches, each under the trigger on its own.
const accounts: TdsAccountInput[] = [
'FD-MUM',
'FD-PUN',
'FD-NAS'
].map((accountNo, i) => ({
accountNo,
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'SINGLE',
clientId: 1,
credits: [{ date: `2026-0${6 + i}-30`, amount: [
20_000,
18_000,
15_000
][i] }]
}));
const collected = collectTdsCredits(1, accounts, FY);
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(53_000);
// Branch by branch none would have crossed ₹50,000. Bank-wide, it does.
expect(quarterlySchedule(collected, general()).totalTds).toBe(5_300);
});
it('keeps other financial years out of the selected year', () => {
const account: TdsAccountInput = {
accountNo: 'FD001',
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'SINGLE',
clientId: 1,
credits: [
{ date: '2026-03-31', amount: 99_000 }, // FY 2025-26
{ date: '2026-06-30', amount: 10_000 }, // FY 2026-27
{ date: '2027-04-01', amount: 99_000 } // FY 2027-28
]
};
expect(collectTdsCredits(1, [account], FY).map((c) => c.amount)).toEqual([10_000]);
});
});
describe('settlement mode follows the product, not the customer', () => {
it.each([
[
'savingsPostingInterestPeriodType.monthly',
'QUARTERLY'
],
[
'savingsPostingInterestPeriodType.quarterly',
'QUARTERLY'
],
[
'savingsPostingInterestPeriodType.annual',
'ANNUAL'
],
[
'savingsPostingInterestPeriodType.annually',
'ANNUAL'
]
])('%s -> %s', (code, expected) => {
expect(tdsModeFor({ interestPostingPeriodType: { code } })).toBe(expected);
});
it('does not mistake a half-yearly product for an annual one', () => {
// 'biannual' contains 'annual' — a naive substring test settles half-yearly
// products once a year, which is a deduction default.
expect(tdsModeFor({ interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.biannual' } })).toBe(
'QUARTERLY'
);
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Bi-Annual' } })).toBe('QUARTERLY');
});
it('reads the value when no code is present, and defaults safely', () => {
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Annually' } })).toBe('ANNUAL');
// Unknown or missing configuration settles on the quarterly cycle, which is
// the conservative choice — deducting sooner is never a default.
expect(tdsModeFor({})).toBe('QUARTERLY');
expect(tdsModeFor({ interestPostingPeriodType: null })).toBe('QUARTERLY');
});
});
describe('PAN validation', () => {
it.each([
[
'ABCPM1234E',
true
],
[
'EFGHV5678A',
true
],
[
'PQRSR2233Z',
false
],
[
'TUVWS3344K',
false
],
[
'ABCP1234E',
false
],
[
'',
false
]
])('%s -> %s', (pan, expected) => {
expect(isWellFormedPan(pan)).toBe(expected);
});
});
const income = (estimatedInterest: number, estimatedIncome: number) => ({
estimatedInterest,
estimatedIncome,
incomeOnRecord: true
});
/** FY 2025-26 — the last year the 1961 Act and the 15G/15H split applied. */
const OLD_FY = 'FY 2025-26';
describe('which declaration form is in force', () => {
it('splits by age up to FY 2025-26', () => {
expect(declarationFormFor(OLD_FY, '1985-02-14')).toBe('15G');
expect(declarationFormFor(OLD_FY, '1958-06-22')).toBe('15H');
});
it('is a single Form 121 from FY 2026-27, whatever the age', () => {
expect(declarationFormFor(FY, '1985-02-14')).toBe('121');
expect(declarationFormFor(FY, '1958-06-22')).toBe('121');
});
it('keeps the senior threshold even though the forms merged', () => {
// The two forms became one, but the deduction trigger is still age-based.
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general()).threshold).toBe(50_000);
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), senior()).threshold).toBe(100_000);
});
});
describe('eligibility up to FY 2025-26 — the 15G / 15H tests', () => {
const old = (o: Partial<TdsContext> = {}) => general({ fy: OLD_FY, ...o });
const oldSenior = (o: Partial<TdsContext> = {}) => senior({ fy: OLD_FY, ...o });
it('accepts a 15G within both the income and the interest ceiling', () => {
const r = checkEligibility(old(), income(90_000, 320_000));
expect(r.form).toBe('15G');
expect(r.eligible).toBe(true);
});
it('rejects a 15G whose estimated income exceeds the basic exemption', () => {
const r = checkEligibility(old(), income(38_500, 850_000));
expect(r.eligible).toBe(false);
expect(r.reasons.join(' ')).toContain('exceeds the basic exemption');
});
it('rejects a 15G whose interest alone exceeds the basic exemption', () => {
// The second limb: interest had to clear the limit as well as income.
expect(checkEligibility(old(), income(450_000, 380_000)).eligible).toBe(false);
});
it('places no interest ceiling on a 15H', () => {
// A senior citizen with ₹8,00,000 of interest could still declare, provided
// deductions and rebate left the liability at nil.
const r = checkEligibility(oldSenior(), income(800_000, 900_000));
expect(r.form).toBe('15H');
expect(r.eligible).toBe(true);
});
it('rejects a 15H that leaves a liability', () => {
const r = checkEligibility(oldSenior(), income(800_000, 1_500_000));
expect(r.eligible).toBe(false);
expect(r.reasons.join(' ')).toContain('nil');
});
it('cites the rule that applied to that year', () => {
const r = checkEligibility(old({ pan: '' }), income(10_000, 100_000));
expect(r.eligible).toBe(false);
expect(r.reasons.join(' ')).toContain('Rule 29C');
});
it('still checks the interest limb when income is not on record', () => {
// Income has to be declared by the payee, but the bank knows the interest
// figure, so that limb remains assessable.
expect(
checkEligibility(old(), { estimatedInterest: 450_000, estimatedIncome: 0, incomeOnRecord: false }).eligible
).toBe(false);
});
});
describe('eligibility from FY 2026-27 — the single Form 121 test', () => {
it('drops the interest limb entirely', () => {
// Under a 15G this failed: interest of ₹4,50,000 was over the basic
// exemption limit. Form 121 asks only whether the tax liability is nil, so
// the same payee is now eligible.
expect(checkEligibility(general({ fy: OLD_FY }), income(450_000, 380_000)).eligible).toBe(false);
const r = checkEligibility(general(), income(450_000, 380_000));
expect(r.form).toBe('121');
expect(r.eligible).toBe(true);
});
it('applies one nil-liability ceiling to every age', () => {
for (const ctx of [
general(),
senior()
]) {
expect(checkEligibility(ctx, income(800_000, 900_000)).eligible).toBe(true);
const over = checkEligibility(ctx, income(800_000, 1_500_000));
expect(over.eligible).toBe(false);
expect(over.reasons.join(' ')).toContain('nil');
}
});
it('takes PAN only — Aadhaar is not a substitute', () => {
const r = checkEligibility(general({ pan: '' }), income(10_000, 100_000));
expect(r.eligible).toBe(false);
expect(r.reasons.join(' ')).toContain('Rule 211');
expect(r.reasons.join(' ')).toContain('Aadhaar is not a substitute');
});
it('refuses an entity PAN', () => {
const r = checkEligibility(general({ pan: 'ABCCM1234E' }), income(10_000, 100_000));
expect(r.eligible).toBe(false);
expect(r.reasons.join(' ')).toContain('entity');
});
it('refuses a non-resident', () => {
expect(checkEligibility(general({ resident: false }), income(10_000, 100_000)).eligible).toBe(false);
});
it('reports the nil-liability test as unassessed rather than passing it silently', () => {
// The branch cannot compute income earned outside the bank, so a missing
// figure must not read as a pass.
const r = checkEligibility(general(), {
estimatedInterest: 90_000,
estimatedIncome: 0,
incomeOnRecord: false
});
expect(r.unassessed.length).toBe(1);
expect(r.unassessed[0]).toContain('not on record');
// With no interest limb left, nothing else can fail it — so the branch must
// read the unassessed note rather than treat the row as cleared.
expect(r.eligible).toBe(true);
});
});
describe('statutory references travel with the year', () => {
it('quotes the 1961 Act for FY 2025-26 and the 2025 Act for FY 2026-27', () => {
expect(FY_TAX_CONFIG['2025-26'].regime).toBe('ITA_1961');
expect(FY_TAX_CONFIG['2025-26'].interestTdsSection).toContain('194A');
expect(FY_TAX_CONFIG['2025-26'].tdsStatementForm).toBe('Form 26Q');
expect(FY_TAX_CONFIG['2025-26'].declarationStatementFrequency).toBe('QUARTERLY');
expect(FY_TAX_CONFIG['2026-27'].regime).toBe('ITA_2025');
expect(FY_TAX_CONFIG['2026-27'].interestTdsSection).toContain('393');
expect(FY_TAX_CONFIG['2026-27'].tdsStatementForm).toBe('Form 140');
expect(FY_TAX_CONFIG['2026-27'].declarationStatementFrequency).toBe('MONTHLY');
});
it('carries the same thresholds and rates across the changeover', () => {
const a = FY_TAX_CONFIG['2025-26'];
const b = FY_TAX_CONFIG['2026-27'];
expect(b.thresholdGeneral).toBe(a.thresholdGeneral);
expect(b.thresholdSenior).toBe(a.thresholdSenior);
expect(b.rate).toBe(a.rate);
expect(b.rateNoPan).toBe(a.rateNoPan);
});
});
describe('default interest under section 201(1A)', () => {
it('counts any part of a month as a full month', () => {
expect(monthsOrPartBetween('2026-07-07', '2026-07-07')).toBe(0);
expect(monthsOrPartBetween('2026-07-07', '2026-07-08')).toBe(1);
expect(monthsOrPartBetween('2026-07-07', '2026-08-07')).toBe(1);
expect(monthsOrPartBetween('2026-07-07', '2026-08-08')).toBe(2);
expect(monthsOrPartBetween('2026-07-07', '2027-01-07')).toBe(6);
});
it('charges 1% a month for deducting late and 1.5% for depositing late', () => {
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEDUCTION', FY)).toEqual({
months: 6,
rate: 0.01,
interest: 360
});
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEPOSIT', FY).interest).toBe(540);
});
it('charges nothing when settled on time', () => {
expect(lateDeductionInterest(6_000, '2026-07-07', '2026-07-07', 'DEPOSIT', FY).interest).toBe(0);
});
});
describe('helpers', () => {
it('resolves the financial year of a date', () => {
expect(fyOf('2026-04-01')).toBe('2026-27');
expect(fyOf('2027-03-31')).toBe('2026-27');
expect(fyOf('2026-03-31')).toBe('2025-26');
expect(fyOf('2027-04-01')).toBe('2027-28');
});
it('buckets dated credits into the right quarters and drops out-of-year dates', () => {
const b = bucketByQuarter(
[
{ date: '2026-04-05', amount: 100 },
{ date: '2026-08-01', amount: 200 },
{ date: '2026-11-30', amount: 300 },
{ date: '2027-01-15', amount: 400 },
{ date: '2026-03-31', amount: 999 },
{ date: '2027-04-01', amount: 999 }
],
FY
);
expect(b).toEqual({ 1: 100, 2: 200, 3: 300, 4: 400 });
});
it('spreads an annual estimate without losing rupees to division', () => {
for (const annual of [
38_500,
52_801,
1,
999_999
]) {
expect(projectQuarterlyCredits(annual, FY).reduce((s, x) => s + x.amount, 0)).toBe(annual);
}
});
it('exposes the cumulative liability directly for ad-hoc checks', () => {
expect(cumulativeLiability(50_000, 50_000, general())).toBe(0);
expect(cumulativeLiability(50_001, 50_001, general())).toBe(5_000);
});
});
File diff suppressed because it is too large Load Diff
@@ -115,51 +115,6 @@
</div> </div>
</div> </div>
} }
<div class="spa-group">
<div class="spa-group__header">
<h4>Map Payment Types to Specific Funds</h4>
<button type="button" class="spa-add-btn" (click)="addPaymentFundSourceMapping()">+ Add</button>
</div>
<div class="spa-list" [formArrayName]="'paymentChannelToFundSourceMappings'">
@for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) {
<div class="spa-other-row-wrap">
<div class="spa-row spa-row--other" [formGroupName]="i">
<div class="spa-row__meta spa-row__meta--other">
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>Payment Type</mat-label>
<mat-select formControlName="paymentTypeId">
@for (paymentType of paymentTypeOptions; track paymentType.id) {
<mat-option [value]="paymentType.id">{{ paymentType.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="spa-row__actions spa-row__actions--other">
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>Fund Source</mat-label>
<mat-select formControlName="fundSourceAccountId">
@for (glAccount of fundSourceAccountOptions; 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="spa-remove-btn spa-remove-btn--side"
(click)="removePaymentFundSourceMapping(i)"
>
<i class="fas fa-times"></i>
</button>
</div>
}
</div>
</div>
} }
</div> </div>
</div> </div>
@@ -50,11 +50,6 @@ type OtherGlMappingValue = {
glAccountId: number | null; glAccountId: number | null;
}; };
type PaymentFundSourceValue = {
paymentTypeId: number | null;
fundSourceAccountId: number | null;
};
@Component({ @Component({
selector: 'mifosx-saving-product-accounting-step', selector: 'mifosx-saving-product-accounting-step',
templateUrl: './saving-product-accounting-step.component.html', templateUrl: './saving-product-accounting-step.component.html',
@@ -83,11 +78,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
expenseAccountOptions: GLAccount[] = []; expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = []; incomeAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = []; assetAccountOptions: GLAccount[] = [];
paymentTypeOptions: any[] = [];
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
* (e.g. an existing "Sundry" account bridging to another module) rather than only a cash-like
* Asset account, so this field accepts either. */
fundSourceAccountOptions: GLAccount[] = [];
accountingSectionConfigs: AccountingSectionConfig[] = []; accountingSectionConfigs: AccountingSectionConfig[] = [];
constructor() { constructor() {
@@ -108,8 +98,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || []; this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || []; this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs(); this.buildAccountingSectionConfigs();
this.savingProductAccountingForm.patchValue({ this.savingProductAccountingForm.patchValue({
accountingRule: accountingRuleId accountingRule: accountingRuleId
@@ -117,7 +105,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.syncAccountingControls(accountingRuleId); this.syncAccountingControls(accountingRuleId);
this.patchAccountingValues(mappings); this.patchAccountingValues(mappings);
this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []);
this.setOverdraftControl(); this.setOverdraftControl();
this.setDormancyControl(); this.setDormancyControl();
} }
@@ -128,48 +115,10 @@ export class SavingProductAccountingStepComponent implements OnInit {
assetsOtherGlMappings: this.formBuilder.array([]), assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]),
expensesOtherGlMappings: this.formBuilder.array([]), expensesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([]), incomeOtherGlMappings: this.formBuilder.array([])
paymentChannelToFundSourceMappings: this.formBuilder.array([])
}); });
} }
get paymentChannelToFundSourceMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
}
private createPaymentFundSourceGroup(value: Partial<PaymentFundSourceValue> = {}) {
return this.formBuilder.group({
paymentTypeId: [
value.paymentTypeId ?? this.paymentTypeOptions[0]?.id ?? null,
Validators.required
],
fundSourceAccountId: [
value.fundSourceAccountId ?? this.fundSourceAccountOptions[0]?.id ?? null,
Validators.required
]
});
}
private patchPaymentFundSourceMappings(mappings: any[]): void {
this.paymentChannelToFundSourceMappings.clear();
(mappings || []).forEach((mapping) => {
this.paymentChannelToFundSourceMappings.push(
this.createPaymentFundSourceGroup({
paymentTypeId: mapping.paymentType?.id,
fundSourceAccountId: mapping.fundSourceAccount?.id
})
);
});
}
addPaymentFundSourceMapping(): void {
this.paymentChannelToFundSourceMappings.push(this.createPaymentFundSourceGroup());
}
removePaymentFundSourceMapping(index: number): void {
this.paymentChannelToFundSourceMappings.removeAt(index);
}
private setConditionalControls() { private setConditionalControls() {
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => { this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
this.syncAccountingControls(accountingRule); this.syncAccountingControls(accountingRule);
@@ -74,12 +74,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
liabilityAccountData: GLAccount[] = []; liabilityAccountData: GLAccount[] = [];
incomeAccountData: GLAccount[] = []; incomeAccountData: GLAccount[] = [];
equityAccountData: GLAccount[] = []; equityAccountData: GLAccount[] = [];
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
* either instead of Asset-only. */
referenceAccountData: GLAccount[] = [];
constructor() { constructor() {
this.createShareProductAccountingForm(); this.createShareProductAccountingForm();
@@ -91,7 +85,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
this.liabilityAccountData = opts.liabilityAccountOptions || []; this.liabilityAccountData = opts.liabilityAccountOptions || [];
this.incomeAccountData = opts.incomeAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || [];
this.equityAccountData = opts.equityAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || [];
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
this.buildAccountingSectionConfigs(); this.buildAccountingSectionConfigs();
@@ -142,10 +135,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
rows: [ rows: [
{ {
key: 'shareReferenceId', key: 'shareReferenceId',
label: 'Share Reference (Cash Vault or Suspense)', label: 'Share Reference (Cash Vault)',
inputLabel: 'Share reference', inputLabel: 'Share reference',
controlName: 'shareReferenceId', controlName: 'shareReferenceId',
options: this.referenceAccountData, options: this.assetAccountData,
required: true required: true
} }
], ],
-12
View File
@@ -59,18 +59,6 @@ const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent) import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
}, },
{
path: 'tds-interest',
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
loadComponent: () =>
import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent)
},
{
path: 'tds-remittance',
data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' },
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
{ {
path: ':filter', path: ':filter',
data: { routeParamBreadcrumb: 'filter' }, data: { routeParamBreadcrumb: 'filter' },
@@ -1,236 +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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
export interface SearchSelectOption {
value: string;
label: string;
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
* that aren't all visible in the collapsed label. */
searchText?: string;
}
/**
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
* it is impractical (e.g. hundreds of accounts) — a text box that filters the option list as you
* type, instead of a native dropdown you can only scroll through. Selection only commits on
* click; typing without picking an option reverts the box to the last committed selection on
* blur, so the bound ngModel value never drifts from an actual, valid option.
*
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
*/
@Component({
selector: 'mifosx-search-select',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchSelectComponent),
multi: true
}
],
template: `
<div class="inv-search-select">
<input
#inputEl
type="text"
class="inv-search-select__input"
[value]="query"
[placeholder]="placeholder"
[disabled]="disabled"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open) {
<ul
class="inv-search-select__list"
[style.top.px]="listTop"
[style.left.px]="listLeft"
[style.width.px]="listWidth"
(mousedown)="$event.preventDefault()"
>
@for (o of filteredOptions; track o.value) {
<li
class="inv-search-select__option"
[class.inv-search-select__option--active]="o.value === value"
(click)="selectOption(o)"
>
{{ o.label }}
</li>
} @empty {
<li class="inv-search-select__empty">No matches</li>
}
</ul>
}
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
* component's own template, so its look is re-declared here rather than inherited. */
:host {
display: block;
width: 100%;
}
.inv-search-select {
position: relative;
width: 100%;
}
.inv-search-select__input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
/* position: fixed, anchored via JS to the input's own screen coords (see
* updateListPosition()) rather than absolute inside this component — most of this app's
* cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown
* that extends past the card's edge. */
.inv-search-select__list {
position: fixed;
z-index: 2000;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
}
.inv-search-select__option {
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
color: #0d1b2a;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inv-search-select__option:hover,
.inv-search-select__option--active {
background: #eef2ff;
}
.inv-search-select__empty {
padding: 8px 10px;
font-size: 12px;
color: #94a3b8;
}
`
]
})
export class SearchSelectComponent implements ControlValueAccessor {
@Input() options: SearchSelectOption[] = [];
@Input() placeholder = 'Search…';
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
value = '';
query = '';
open = false;
disabled = false;
listTop = 0;
listLeft = 0;
listWidth = 0;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
get filteredOptions(): SearchSelectOption[] {
const q = this.query.trim().toLowerCase();
if (!q) {
return this.options;
}
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
}
private labelFor(value: string): string {
return this.options.find((o) => o.value === value)?.label ?? '';
}
writeValue(v: string): void {
this.value = v ?? '';
this.query = this.labelFor(this.value);
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/** Anchors the fixed-position list to the input's current on-screen box. */
private updateListPosition(): void {
const el = this.inputEl?.nativeElement;
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
this.listTop = rect.bottom + 4;
this.listLeft = rect.left;
this.listWidth = rect.width;
}
@HostListener('window:scroll')
@HostListener('window:resize')
onWindowChange(): void {
if (this.open) {
this.updateListPosition();
}
}
onInput(event: Event): void {
this.query = (event.target as HTMLInputElement).value;
this.open = true;
this.updateListPosition();
}
onFocus(): void {
this.open = true;
this.updateListPosition();
}
onBlur(): void {
this.open = false;
this.onTouched();
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
this.query = this.labelFor(this.value);
}
selectOption(o: SearchSelectOption): void {
this.value = o.value;
this.query = o.label;
this.open = false;
this.onChangeFn(this.value);
this.onTouched();
}
}
@@ -20,11 +20,11 @@
</div> </div>
<div class="table-filters"> <div class="table-filters">
<mat-form-field class="table-search" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="table-search" appearance="outline">
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" /> <input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
</mat-form-field> </mat-form-field>
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch"> <mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
<mat-option value="">All Branches</mat-option> <mat-option value="">All Branches</mat-option>
<mat-option *ngFor="let office of offices" [value]="office.id"> <mat-option *ngFor="let office of offices" [value]="office.id">
@@ -33,7 +33,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product"> <mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
<mat-option value="">All Products</mat-option> <mat-option value="">All Products</mat-option>
<mat-option *ngFor="let product of products" [value]="product.id"> <mat-option *ngFor="let product of products" [value]="product.id">
@@ -42,7 +42,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status"> <mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option> <mat-option value="">All Status</mat-option>
<mat-option value="active">Active</mat-option> <mat-option value="active">Active</mat-option>
@@ -53,7 +53,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field class="table-filter" appearance="outline" subscriptSizing="dynamic" (click)="picker.open()"> <mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker"> <mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" /> <input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" /> <input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
@@ -5,43 +5,37 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
:host {
display: block;
width: 100%;
}
.container { .container {
margin: 1.5rem auto; margin: 1.5rem;
max-width: 1400px; padding: 0;
padding: 20px 24px; border-radius: var(--radius-xl);
border-radius: 16px; background: var(--bg-card);
background: #ffffff; box-shadow: var(--shadow-sm);
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
} }
.table-header { .table-header {
margin-bottom: 20px; padding: 1.5rem;
border-bottom: 1px solid var(--border-light);
.table-header-row { .table-header-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 16px; margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
} }
.table-title-group { .table-title-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 1rem;
.table-title { .table-title {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: #0f172a; color: var(--text-main);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -51,175 +45,83 @@
width: 24px; width: 24px;
height: 24px; height: 24px;
position: relative; position: relative;
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-wrap: wrap;
gap: 2px; gap: 2px;
padding: 2px; padding: 2px;
background: #f1f5f9; background: var(--bg-hover);
border-radius: 6px; border-radius: 6px;
span { span {
width: 8px;
height: 8px;
border-radius: 2px; border-radius: 2px;
background: #1353a0; background: var(--primary-main);
opacity: 0.8;
&:nth-child(2) { opacity: 0.7; } &:nth-child(2) {
&:nth-child(3) { opacity: 0.4; } opacity: 0.6;
&:nth-child(4) { opacity: 0.2; } }
&:nth-child(3) {
opacity: 0.4;
}
&:nth-child(4) {
opacity: 0.2;
}
} }
} }
} }
.table-count { .table-count {
padding: 0.2rem 0.65rem; padding: 0.25rem 0.75rem;
background: #f1f5f9; background: var(--bg-hover);
color: #64748b; color: var(--text-muted);
border-radius: 12px; border-radius: var(--radius-full);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
font-family: 'Roboto Mono', monospace; letter-spacing: 0.02em;
} }
} }
/* Pill-styled filter bar */
.table-filters { .table-filters {
display: flex; display: flex;
gap: 1rem;
align-items: center; align-items: center;
gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
.table-search { mat-form-field {
flex: 1 1 200px; width: 240px;
min-width: 180px; margin-bottom: -1.25em;
max-width: 280px;
margin: 0;
}
.table-filter {
flex: 0 1 150px;
min-width: 130px;
max-width: 170px;
margin: 0;
}
/* Override Material Form Field Heights & Subscript Spacing */
::ng-deep {
.mat-mdc-form-field {
margin: 0;
}
.mat-mdc-form-field-subscript-wrapper {
display: none !important;
height: 0 !important;
}
.mat-mdc-form-field-flex {
height: 38px !important;
background: #ffffff !important;
align-items: center;
}
.mat-mdc-form-field-infix {
padding: 6px 0 !important;
min-height: 38px !important;
}
.mdc-notched-outline__leading {
border-radius: 8px 0 0 8px !important;
}
.mdc-notched-outline__trailing {
border-radius: 0 8px 8px 0 !important;
}
.mat-mdc-select-value,
.mat-mdc-select-placeholder,
input {
font-size: 13px !important;
}
}
button[mat-icon-button] {
color: #64748b;
width: 36px;
height: 36px;
&:hover {
color: #0f172a;
background: #e2e8f0;
}
} }
} }
} }
.table-container { .table-container {
border: 1px solid #e2e8f0; position: relative;
border-radius: 12px; overflow: auto;
overflow: hidden; border-radius: 0 0 var(--radius-xl) var(--radius-xl);
background: #ffffff;
.table-loader { .table-loader {
height: 2px; position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
} }
} }
.modern-table { .modern-table {
width: 100%; width: 100%;
border-collapse: separate; background: transparent;
border-spacing: 0;
::ng-deep { tr.mat-row {
.mat-mdc-header-row { cursor: pointer;
background: #f8fafc; transition: background-color 0.2s ease;
min-height: 38px;
height: 38px;
}
.mat-mdc-header-cell { &:hover {
font-size: 11px; background-color: var(--bg-hover);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
border-bottom: 1px solid #e2e8f0;
padding: 0 16px;
white-space: nowrap;
}
.mat-mdc-row {
min-height: 42px;
height: 42px;
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.15s ease;
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:nth-child(even) {
background: #fafbfc;
}
&:hover {
background-color: #f1f5f9 !important;
}
}
.mat-mdc-cell {
font-size: 13px;
font-weight: 400;
color: #0f172a;
padding: 0 16px;
white-space: nowrap;
} }
} }
} }
mat-paginator {
border-top: 1px solid #e2e8f0;
background: #ffffff;
}
@@ -1,210 +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="passbook-container">
<!-- Filter Bar -->
<div class="passbook-filter-bar no-print">
<form [formGroup]="dateRangeForm" class="passbook-filter-form">
<mat-form-field appearance="outline" class="passbook-date-field" (click)="fromDatePicker.open()">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="fromDatePicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="fromDatePicker"></mat-datepicker-toggle>
<mat-datepicker #fromDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="passbook-date-field" (click)="toDatePicker.open()">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [min]="minDate" [max]="maxDate" [matDatepicker]="toDatePicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="toDatePicker"></mat-datepicker-toggle>
<mat-datepicker #toDatePicker></mat-datepicker>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
[disabled]="!dateRangeForm.valid"
(click)="filterTransactions()"
>
<mat-icon>filter_list</mat-icon>
{{ 'labels.buttons.Filter' | translate }}
</button>
@if (isFiltered) {
<button mat-stroked-button type="button" (click)="clearFilter()">
<mat-icon>clear</mat-icon>
{{ 'labels.buttons.Clear' | translate }}
</button>
}
<span class="passbook-filter-spacer"></span>
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
<mat-icon>print</mat-icon>
{{ 'labels.buttons.Print' | translate }}
</button>
</form>
</div>
<!-- Share Passbook -->
<div class="statement-wrapper" #passbookContent>
<!-- Bank Header -->
<div class="bank-header">
<div class="bank-logo-area">
<div class="bank-logo-circle">
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
<div class="bank-subtitle">Share Account Passbook</div>
</div>
</div>
<div class="statement-label">
<div class="stmt-label-text">SHARE HOLDING STATEMENT</div>
<div class="stmt-period">As on: {{ maxDate | dateFormat }}</div>
</div>
</div>
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
<div class="detail-block">
<div class="detail-label">Member Name</div>
<div class="detail-value bold">{{ clientName | uppercase }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Share Account No.</div>
<div class="detail-value mono bold">{{ accountNo }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Product</div>
<div class="detail-value">{{ productName }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Opening Date</div>
<div class="detail-value">{{ activatedOnDate | dateFormat }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Status</div>
<div class="detail-value status-badge" [class.active]="status === 'Active'">{{ status }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Linked Savings A/C No.</div>
<div class="detail-value mono">{{ savingsAccountNumber || '—' }}</div>
</div>
@if (resolutionNumber) {
<div class="detail-block">
<div class="detail-label">Resolution No.</div>
<div class="detail-value">{{ resolutionNumber }}</div>
</div>
}
</div>
</div>
<!-- Balance Summary Strip -->
<div class="balance-summary-strip">
<div class="balance-box">
<div class="balance-box-label">Approved Shares</div>
<div class="balance-box-value">{{ totalApprovedShares }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Share Value</div>
<div class="balance-box-value">
{{ currentMarketPrice | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Current Value</div>
<div class="balance-box-value">{{ currentValue | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Total Transactions</div>
<div class="balance-box-value">{{ filteredTransactions.length }}</div>
</div>
</div>
<!-- Transactions Table -->
@if (filteredTransactions.length > 0) {
<table class="statement-table">
<thead>
<tr>
<th class="col-sr">#</th>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref No.</th>
<th class="col-amount text-right">Shares Purchased</th>
<th class="col-amount text-right">Shares Redeemed</th>
<th class="col-balance text-right">Share Balance</th>
<th class="col-amount text-right">Amount (&#8377;)</th>
<th class="col-desc">Resolution No. / Meeting</th>
</tr>
</thead>
<tbody>
@for (txn of filteredTransactions; track txn.id; let i = $index) {
<tr [class.debit-row]="isRedeem(txn)" [class.credit-row]="!isRedeem(txn)">
<td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ txn.purchasedDate | dateFormat }}</td>
<td class="col-desc">
<span class="txn-desc">{{ txn.type.value }} ({{ txn.status.value }})</span>
</td>
<td class="col-ref text-muted mono">{{ txn.id }}</td>
<td class="col-amount text-right credit-amount">
@if (!isRedeem(txn)) {
{{ txn.numberOfShares }}
}
</td>
<td class="col-amount text-right debit-amount">
@if (isRedeem(txn)) {
{{ txn.numberOfShares }}
}
</td>
<td class="col-balance text-right balance-amount">{{ txn.runningBalance }}</td>
<td class="col-amount text-right">
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</td>
<td class="col-desc">
@if (isRedeem(txn)) {
{{ txn.resolutionNumber || '—' }}
@if (txn.meetingDate) {
({{ txn.meetingDate | dateFormat }})
}
} @else {
—
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="6" class="text-right total-label">Closing Share Balance</td>
<td class="text-right closing-balance">{{ totalApprovedShares }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
} @else {
<div class="no-data-block">
<mat-icon>inbox</mat-icon>
<p>No transactions found for the selected date range.</p>
</div>
}
<!-- Statement Footer -->
<div class="statement-footer">
<div class="footer-note">
This is a computer-generated statement and does not require a signature. For queries, contact your branch or
call customer care.
</div>
<div class="footer-meta">Generated on: {{ maxDate | dateFormat }} &nbsp;|&nbsp; Account No: {{ accountNo }}</div>
</div>
</div>
</div>
@@ -1,462 +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/.
*/
// ─── Variables ──────────────────────────────────────────────────────────────
$bank-primary: #1a3a6b; // deep navy
$bank-accent: #c8a951; // gold trim
$debit-color: #c0392b;
$credit-color: #1a7a3f;
$balance-color: #1a3a6b;
$border-color: #d0d7e3;
$row-alt: #f4f7fb;
// ─── Container ──────────────────────────────────────────────────────────────
.passbook-container {
padding: 16px;
background: #eef1f7;
min-height: 100%;
}
// ─── Filter Bar ─────────────────────────────────────────────────────────────
.passbook-filter-bar {
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid $border-color;
}
.passbook-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.passbook-date-field {
width: 180px;
}
.passbook-filter-spacer {
flex: 1;
}
// ─── Statement Wrapper ───────────────────────────────────────────────────────
.statement-wrapper {
background: #fff;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
font-family: 'Segoe UI', Arial, sans-serif;
}
// ─── Bank Header ─────────────────────────────────────────────────────────────
.bank-header {
background: $bank-primary;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid $bank-accent;
}
.bank-logo-area {
display: flex;
align-items: center;
gap: 14px;
}
.bank-logo-circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: $bank-accent;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: $bank-primary;
font-size: 28px;
width: 28px;
height: 28px;
}
}
.bank-name-block {
.bank-title {
color: #fff;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-subtitle {
color: $bank-accent;
font-size: 11px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
}
.statement-label {
text-align: right;
.stmt-label-text {
color: $bank-accent;
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.stmt-period {
color: rgb(255 255 255 / 75%);
font-size: 11px;
margin-top: 4px;
}
}
// ─── Account Details Band ────────────────────────────────────────────────────
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid $border-color;
padding: 14px 24px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 24px;
}
.detail-block {
.detail-label {
font-size: 9px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 3px;
}
.detail-value {
font-size: 13px;
color: #222;
&.bold {
font-weight: 600;
}
&.mono {
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
&.status-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #eee;
color: #555;
&.active {
background: #d4edda;
color: #155724;
}
}
}
}
// ─── Balance Summary Strip ───────────────────────────────────────────────────
.balance-summary-strip {
display: flex;
align-items: stretch;
background: $bank-primary;
padding: 0;
}
.balance-box {
flex: 1;
padding: 12px 20px;
text-align: center;
.balance-box-label {
font-size: 10px;
font-weight: 600;
color: rgb(255 255 255 / 65%);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.balance-box-value {
font-size: 16px;
font-weight: 700;
color: $bank-accent;
}
}
.balance-divider {
width: 1px;
background: rgb(255 255 255 / 15%);
margin: 8px 0;
}
// ─── Transactions Table ───────────────────────────────────────────────────────
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead tr {
background: #e8edf5;
border-top: 1px solid $border-color;
border-bottom: 2px solid $bank-primary;
}
thead th {
padding: 9px 10px;
font-size: 11px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid #e8ecf3;
transition: background 0.1s;
&:nth-child(even) {
background: $row-alt;
}
&:hover {
background: #dce6f5;
}
}
tbody td {
padding: 7px 10px;
vertical-align: middle;
color: #333;
}
tfoot .total-row {
background: #e8edf5;
border-top: 2px solid $bank-primary;
td {
padding: 9px 10px;
}
}
}
// ─── Column widths ────────────────────────────────────────────────────────────
.col-sr {
width: 38px;
}
.col-date {
width: 100px;
white-space: nowrap;
}
.col-desc {
min-width: 140px;
}
.col-ref {
width: 70px;
}
.col-amount {
width: 100px;
}
.col-balance {
width: 90px;
}
// ─── Amount colors ────────────────────────────────────────────────────────────
.debit-amount {
color: $debit-color;
font-weight: 500;
}
.credit-amount {
color: $credit-color;
font-weight: 500;
}
.balance-amount {
color: $balance-color;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.total-label {
font-size: 12px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 14px;
font-weight: 700;
color: $bank-primary;
font-family: 'Courier New', monospace;
}
// ─── Badges & misc ────────────────────────────────────────────────────────────
.txn-desc {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: #999;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 11px;
}
// ─── No Data ──────────────────────────────────────────────────────────────────
.no-data-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #aaa;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 12px;
}
p {
font-size: 14px;
margin: 0;
}
}
// ─── Statement Footer ─────────────────────────────────────────────────────────
.statement-footer {
background: #f0f3f9;
border-top: 1px solid $border-color;
padding: 12px 24px;
text-align: center;
.footer-note {
font-size: 10px;
color: #888;
margin-bottom: 4px;
font-style: italic;
}
.footer-meta {
font-size: 10px;
color: #aaa;
letter-spacing: 0.3px;
}
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (width <= 768px) {
.account-details-grid {
grid-template-columns: 1fr 1fr;
}
.bank-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.statement-label {
text-align: left;
}
.balance-summary-strip {
flex-direction: column;
}
.balance-divider {
width: auto;
height: 1px;
margin: 0 20px;
}
.passbook-filter-form {
flex-direction: column;
align-items: stretch;
}
.passbook-date-field {
width: 100%;
}
.passbook-filter-spacer {
display: none;
}
}
@media (width <= 480px) {
.account-details-grid {
grid-template-columns: 1fr;
}
}
// ─── Print ────────────────────────────────────────────────────────────────────
@media print {
.no-print {
display: none !important;
}
.passbook-container {
background: #fff;
padding: 0;
}
.statement-wrapper {
border: none;
box-shadow: none;
border-radius: 0;
}
.bank-header {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.balance-summary-strip {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -1,182 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, ElementRef, ViewChild, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, Validators } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
* Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings:
* shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution
* Number / Date of Meeting on redeem rows.
*/
@Component({
selector: 'mifosx-shares-passbook-tab',
templateUrl: './passbook-tab.component.html',
styleUrls: ['./passbook-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon,
DateFormatPipe,
CurrencyPipe
]
})
export class SharesPassbookTabComponent implements OnInit {
private route = inject(ActivatedRoute);
private formBuilder = inject(UntypedFormBuilder);
@ViewChild('passbookContent') passbookContent: ElementRef;
/** Account data */
accountNo = '';
clientName = '';
productName = '';
currency: any = null;
status = '';
activatedOnDate: any = null;
resolutionNumber = '';
savingsAccountNumber = '';
totalApprovedShares = 0;
currentMarketPrice = 0;
/** Transactions */
allTransactions: any[] = [];
filteredTransactions: any[] = [];
/** Date range form */
minDate = new Date(2000, 0, 1);
maxDate = new Date();
dateRangeForm: any;
isFiltered = false;
get currentValue(): number {
return this.totalApprovedShares * this.currentMarketPrice;
}
ngOnInit(): void {
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
const account = data.sharesAccountData;
this.accountNo = account.accountNo || '';
this.clientName = account.clientName || '';
this.productName = account.productName || '';
this.currency = account.currency || null;
this.status = account.status?.value || '';
this.activatedOnDate = account.timeline?.activatedDate || null;
this.resolutionNumber = account.resolutionNumber || '';
this.savingsAccountNumber = account.savingsAccountNumber || '';
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
this.currentMarketPrice = account.currentMarketPrice || 0;
const transactions = (account.purchasedShares || []).filter(
(transaction: any) => transaction.type.value !== 'Charge Payment'
);
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
this.filteredTransactions = [...this.allTransactions];
});
this.dateRangeForm = this.formBuilder.group({
fromDate: [
'',
Validators.required
],
toDate: [
'',
Validators.required
]
});
}
isRedeem(transaction: any): boolean {
return transaction.type?.value === 'Redeem';
}
filterTransactions(): void {
const fromDate = this.dateRangeForm.value.fromDate;
const toDate = this.dateRangeForm.value.toDate;
if (!fromDate || !toDate) return;
const from = new Date(fromDate).getTime();
const to = new Date(toDate).getTime();
this.filteredTransactions = this.allTransactions.filter((txn: any) => {
const txnDate = this.getTransactionDateAsTime(txn.purchasedDate);
return txnDate >= from && txnDate <= to;
});
this.isFiltered = true;
}
clearFilter(): void {
this.dateRangeForm.reset();
this.filteredTransactions = [...this.allTransactions];
this.isFiltered = false;
}
private getTransactionDateAsTime(date: any): number {
if (Array.isArray(date) && date.length >= 3) {
return new Date(date[0], date[1] - 1, date[2]).getTime();
}
return new Date(date).getTime();
}
printPassbook(): void {
const content = this.passbookContent?.nativeElement?.innerHTML;
if (!content) return;
const printWindow = window.open('', 'SharePassbook', 'height=700,width=900');
printWindow.document.write(`
<html>
<head>
<title>Share Account Passbook - ${this.accountNo}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 20px; color: #333; }
.passbook-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #1a3a6b; padding-bottom: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th { background: #1a3a6b; color: #fff; padding: 6px 8px; text-align: left; font-weight: 600; }
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) { background: #fafafa; }
.text-right { text-align: right; }
</style>
</head>
<body>
${content}
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
}
private recalculateRunningBalances(transactions: any[]): any[] {
let balance = 0;
return transactions.map((txn) => {
const shares = Number(txn.numberOfShares) || 0;
balance += this.isRedeem(txn) ? -shares : shares;
return { ...txn, runningBalance: balance };
});
}
private sortAscending(transactions: any[]): any[] {
return [...transactions].sort((a, b) => {
const dateA = this.getTransactionDateAsTime(a.purchasedDate);
const dateB = this.getTransactionDateAsTime(b.purchasedDate);
if (dateA !== dateB) return dateA - dateB;
return (a.id as number) - (b.id as number);
});
}
}
@@ -145,16 +145,6 @@
> >
{{ 'labels.inputs.Transactions' | translate }} {{ 'labels.inputs.Transactions' | translate }}
</a> </a>
<a
mat-tab-link
[routerLink]="['./passbook']"
routerLinkActive
#passbook="routerLinkActive"
[active]="passbook.isActive"
*mifosxHasPermission="'READ_SHAREACCOUNTPURCHASE'"
>
Passbook
</a>
<a <a
mat-tab-link mat-tab-link
[routerLink]="['./charges']" [routerLink]="['./charges']"
@@ -50,10 +50,12 @@ export class SharesButtonsConfiguration {
name: 'Reject Additional Shares', name: 'Reject Additional Shares',
icon: 'arrow-left', icon: 'arrow-left',
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT' taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT'
},
{
name: 'Redeem Shares',
icon: 'arrow-left',
taskPermissionName: 'WITHDRAW_SAVINGSACCOUNT'
} }
// 'Redeem Shares' is intentionally not offered here — a share withdrawal must go
// through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Requests),
// never a direct one-step action from this view.
]; ];
break; break;
case 'Submitted and pending approval': case 'Submitted and pending approval':
@@ -54,25 +54,6 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="runningBalance">
<th mat-header-cell *matHeaderCellDef>Share Balance</th>
<td mat-cell *matCellDef="let transaction">{{ transaction.runningBalance }}</td>
</ng-container>
<ng-container matColumnDef="resolutionMeeting">
<th mat-header-cell *matHeaderCellDef>Resolution No. / Meeting Date</th>
<td mat-cell *matCellDef="let transaction">
@if (transaction.type.value === 'Redeem') {
{{ transaction.resolutionNumber || '—' }}
@if (transaction.meetingDate) {
({{ transaction.meetingDate | dateFormat }})
}
} @else {
—
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table> </table>
@@ -56,18 +56,14 @@ export class TransactionsTabComponent implements OnInit {
transactionsData: any; transactionsData: any;
/** Data source for transactions table. */ /** Data source for transactions table. */
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
/** Columns to be displayed in transactions table — a share account's passbook: every /** Columns to be displayed in transactions table. */
* purchase/redeem entry plus the running share balance and, for a withdrawal, the Board
* Resolution Number / Date of Meeting that authorised it. */
displayedColumns: string[] = [ displayedColumns: string[] = [
'transactionDate', 'transactionDate',
'transactionType', 'transactionType',
'totalShares', 'totalShares',
'purchasedOrRedeemedPrice', 'purchasedOrRedeemedPrice',
'chargeAmount', 'chargeAmount',
'amountRecievedOrReturned', 'amountRecievedOrReturned'
'runningBalance',
'resolutionMeeting'
]; ];
/** /**
@@ -77,19 +73,9 @@ export class TransactionsTabComponent implements OnInit {
constructor() { constructor() {
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
this.shareAccountData = data.sharesAccountData; this.shareAccountData = data.sharesAccountData;
this.transactionsData = this.shareAccountData.purchasedShares this.transactionsData = this.shareAccountData.purchasedShares.filter(
.filter((transaction: any) => transaction.type.value !== 'Charge Payment') (transaction: any) => transaction.type.value !== 'Charge Payment'
.slice() );
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
let runningBalance = 0;
this.transactionsData.forEach((transaction: any) => {
const shares = Number(transaction.numberOfShares) || 0;
runningBalance += transaction.type.value === 'Redeem' ? -shares : shares;
transaction.runningBalance = runningBalance;
});
// Passbook reads newest-first.
this.transactionsData.reverse();
}); });
} }
-6
View File
@@ -26,7 +26,6 @@ import { ShareAccountActionsResolver } from './common-resolvers/share-account-ac
import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver'; import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver';
import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver'; import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver';
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component'; import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component';
/** Shares Routes */ /** Shares Routes */
const routes: Routes = [ const routes: Routes = [
@@ -76,11 +75,6 @@ const routes: Routes = [
component: TransactionsTabComponent, component: TransactionsTabComponent,
data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false } data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false }
}, },
{
path: 'passbook',
component: SharesPassbookTabComponent,
data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false }
},
{ {
path: 'charges', path: 'charges',
component: ChargesTabComponent, component: ChargesTabComponent,
@@ -1,77 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h1 mat-dialog-title class="ced-title">
<span class="ced-title__icon">✉</span>
Approval Email
<div class="ced-status-row">
@if (data.emailSent) {
<span class="ced-chip ced-chip--sent"> <span class="ced-chip__dot"></span>Sent </span>
} @else {
<span class="ced-chip ced-chip--notsent"> <span class="ced-chip__dot"></span>Not sent </span>
}
@if (data.emailOpenedAt) {
<span class="ced-chip ced-chip--seen"> <fa-icon icon="eye" class="ced-chip__icon"></fa-icon>Seen </span>
} @else {
<span class="ced-chip ced-chip--notseen">
<fa-icon icon="eye-slash" class="ced-chip__icon"></fa-icon>Not yet seen
</span>
}
</div>
</h1>
<div mat-dialog-content class="ced-content">
<!-- Classic Email Header (Gmail Style) -->
<div class="ced-gmail-header">
<h2 class="ced-gmail-subject">{{ data.emailSubject ?? '(No Subject)' }}</h2>
<div class="ced-gmail-meta">
<div class="ced-gmail-avatar">
<div class="ced-gmail-avatar-letter">C</div>
</div>
<div class="ced-gmail-meta-content">
<div class="ced-gmail-meta-top">
<span class="ced-gmail-sender-name">CBSNexora</span>
<span class="ced-gmail-sender-email">&lt;{{ data.emailFrom ?? 'noreply@cbsnexora.in' }}&gt;</span>
<span class="ced-gmail-spacer"></span>
<span class="ced-gmail-date">{{ data.emailSentAt ? formatDate(data.emailSentAt) : '—' }}</span>
</div>
<div class="ced-gmail-meta-bottom">
<span class="ced-gmail-to">
to
@if (data.emailTo) {
{{ data.emailTo }}
} @else {
<span style="font-style: italic; color: #d32f2f">No email on file</span>
}
</span>
<fa-icon icon="caret-down" class="ced-gmail-caret"></fa-icon>
</div>
</div>
</div>
</div>
<!-- HTML email body -->
@if (data.emailBody) {
<div class="ced-email-body" [innerHTML]="safeBody"></div>
} @else {
<div class="ced-empty">
<p>No email content available.</p>
@if (!data.emailSent) {
<p class="ced-empty__hint">The email has not been sent yet for this edit.</p>
}
</div>
}
</div>
<mat-dialog-actions align="end">
<button mat-raised-button color="primary" mat-dialog-close>Close</button>
</mat-dialog-actions>
@@ -1,234 +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/.
*/
:host {
display: block;
}
.ced-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.15rem;
font-weight: 600;
color: #1a237e;
position: relative; /* For aligning badges to the right */
&__icon {
font-size: 1.3rem;
}
}
.ced-content {
min-width: 560px;
max-width: 760px;
max-height: 72vh;
overflow-y: auto;
padding: 0 4px;
}
/* ── Gmail-Style Email Header ─────────────────────────────────────────────────── */
.ced-gmail-header {
display: flex;
flex-direction: column;
margin-bottom: 30px;
padding: 0 4px;
font-family: Roboto, Inter, sans-serif; /* Gmail-like typography */
}
.ced-gmail-subject {
font-size: 1.35rem;
font-weight: 400;
color: #202124;
margin: 0 0 24px;
line-height: 1.4;
}
.ced-gmail-meta {
display: flex;
align-items: center; /* Avatar vertically centered with text */
gap: 12px;
}
.ced-gmail-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f06292;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ced-gmail-avatar-letter {
color: white;
font-size: 1.25rem;
font-weight: 500;
}
.ced-gmail-meta-content {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
gap: 2px;
}
.ced-gmail-meta-top {
display: flex;
align-items: baseline;
width: 100%;
}
.ced-gmail-spacer {
flex-grow: 1;
}
.ced-gmail-sender-name {
font-size: 0.95rem;
font-weight: 600;
color: #202124;
margin-right: 6px;
}
.ced-gmail-sender-email {
color: #5f6368;
font-size: 0.8rem;
}
.ced-gmail-date {
color: #5f6368;
font-size: 0.75rem;
white-space: nowrap;
}
.ced-gmail-meta-bottom {
display: flex;
align-items: center;
gap: 4px;
}
.ced-gmail-to {
font-size: 0.75rem;
color: #5f6368;
}
.ced-gmail-caret {
font-size: 0.75rem;
color: #5f6368;
cursor: pointer;
}
.ced-status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: auto; /* Pushes badges to the right in the flex container */
}
/* ── Status chips ───────────────────────────────────────────────────────── */
.ced-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px 4px 10px;
border-radius: 20px;
font-size: 0.82rem;
font-weight: 500;
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&__icon {
font-size: 0.82rem;
}
&--sent {
background: #e8f5e9;
color: #1b5e20;
.ced-chip__dot {
background: #43a047;
}
}
&--notsent {
background: #f5f5f5;
color: #757575;
.ced-chip__dot {
background: #bdbdbd;
}
}
&--seen {
background: #e3f2fd;
color: #0d47a1;
}
&--notseen {
background: #f5f5f5;
color: #9e9e9e;
}
}
/* ── Divider ────────────────────────────────────────────────────────────── */
.ced-divider {
border: none;
border-top: 1px solid #eee;
margin: 12px 0;
}
/* ── Email body iframe-like container ───────────────────────────────────── */
.ced-email-body {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px 24px;
font-family: Arial, sans-serif;
font-size: 0.93rem;
line-height: 1.6;
color: #333;
// Reset any Angular Material overrides inside the rendered HTML
table {
border-collapse: collapse;
width: 100%;
}
td,
th {
padding: 6px 12px;
border: 1px solid #e0e0e0;
}
p {
margin: 0 0 10px;
}
ul {
padding-left: 20px;
}
}
/* ── Empty state ────────────────────────────────────────────────────────── */
.ced-empty {
text-align: center;
padding: 32px 0;
color: #9e9e9e;
&__hint {
font-size: 0.85rem;
margin-top: 6px;
}
}
@@ -1,69 +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,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface ClientEditEmailDialogData {
emailSubject: string | null;
emailBody: string | null;
emailSent: boolean;
emailSentAt: string | null;
emailOpenedAt: string | null;
emailFrom: string | null;
emailTo: string | null;
}
/** Displays the exact confirmation email sent to the client when their pending edit was approved.
* Shows subject, HTML body (rendered), sent timestamp, and read/seen status. */
@Component({
selector: 'mifosx-client-edit-email-dialog',
templateUrl: './client-edit-email-dialog.component.html',
styleUrls: ['./client-edit-email-dialog.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
FaIconComponent
]
})
export class ClientEditEmailDialogComponent {
data = inject<ClientEditEmailDialogData>(MAT_DIALOG_DATA);
private sanitizer = inject(DomSanitizer);
get safeBody(): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(this.data.emailBody ?? '');
}
formatDate(isoDate: string | null | undefined): string {
if (!isoDate) return '—';
const d = new Date(isoDate);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
});
}
}
@@ -126,29 +126,6 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th> <th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit"> <td mat-cell *matCellDef="let edit">
<!-- ── Email status badges (compact, always visible) ── -->
<span
class="pce-email-badge"
[class.pce-email-badge--sent]="edit.emailSent"
[class.pce-email-badge--nosent]="!edit.emailSent"
[title]="edit.emailSent ? 'Email sent on ' + formatDate(edit.emailSentAt) : 'No confirmation email sent'"
>
<fa-icon icon="envelope"></fa-icon>
{{ edit.emailSent ? 'Sent' : 'Not Sent' }}
</span>
@if (edit.emailSent) {
<span
class="pce-email-badge"
[class.pce-email-badge--seen]="edit.emailOpenedAt"
[class.pce-email-badge--notseen]="!edit.emailOpenedAt"
[title]="edit.emailOpenedAt ? 'Opened on ' + formatDate(edit.emailOpenedAt) : 'Not yet opened'"
>
<fa-icon [icon]="edit.emailOpenedAt ? 'eye' : 'eye-slash'"></fa-icon>
{{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }}
</span>
}
<!-- ── Action buttons ── -->
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)"> <button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare <fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
</button> </button>
@@ -160,16 +137,6 @@
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject <fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button> </button>
} }
@if (edit.emailSent) {
<button
mat-stroked-button
class="pce-btn pce-btn-email"
(click)="viewEmail(edit)"
title="View the exact confirmation email sent to the client"
>
<fa-icon icon="envelope" class="m-r-10"></fa-icon>View Email
</button>
}
</td> </td>
</ng-container> </ng-container>
@@ -346,45 +346,3 @@ $text-muted: #64748b;
line-height: 1.5; line-height: 1.5;
} }
} }
/* ── Email badges ───────────────────────────────────────────────────────── */
.pce-email-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
margin-right: 6px;
white-space: nowrap;
vertical-align: middle;
fa-icon {
font-size: 0.7rem;
}
&--sent {
background: #e8f5e9;
color: #1b5e20;
border: 1px solid #c8e6c9;
}
&--nosent {
background: #f5f5f5;
color: #9e9e9e;
border: 1px solid #e0e0e0;
}
&--seen {
background: #e3f2fd;
color: #0d47a1;
border: 1px solid #bbdefb;
}
&--notseen {
background: #fff3e0;
color: #e65100;
border: 1px solid #ffe0b2;
}
}
@@ -34,7 +34,6 @@ import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util'; import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component'; import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
import { ClientEditEmailDialogComponent } from './client-edit-email-dialog/client-edit-email-dialog.component';
/** Maps a payload key that holds a CodeValue/template id to the options array it should be /** Maps a payload key that holds a CodeValue/template id to the options array it should be
* resolved against — populated from /clients/template plus the same per-code-value lookups the * resolved against — populated from /clients/template plus the same per-code-value lookups the
@@ -98,8 +97,7 @@ const NATIONALITY_LABELS: Record<string, string> = {
MatRowDef, MatRowDef,
MatRow, MatRow,
MatIcon, MatIcon,
MatPaginatorModule, MatPaginatorModule
ClientEditEmailDialogComponent
] ]
}) })
export class PendingClientEditsComponent implements OnInit { export class PendingClientEditsComponent implements OnInit {
@@ -425,52 +423,6 @@ export class PendingClientEditsComponent implements OnInit {
}); });
} }
/** Opens the email preview dialog for an edit row. Fetches the email content first (subject,
* body, sent/opened timestamps) via the email-preview endpoint, then opens the dialog. */
viewEmail(edit: any): void {
this.clientsService.getClientEditEmailPreview(edit.clientId, edit.id).subscribe({
next: (preview) => {
try {
this.dialog.open(ClientEditEmailDialogComponent, {
width: '800px',
maxWidth: '96vw',
data: {
emailSubject: preview?.emailSubject || null,
emailBody: preview?.emailBody || null,
emailSent: preview?.emailSent || false,
emailSentAt: preview?.emailSentAt || null,
emailOpenedAt: preview?.emailOpenedAt || null,
emailFrom: preview?.emailFrom || null,
emailTo: preview?.emailTo || null
}
});
} catch (e: any) {
console.error('Dialog open failed (next): ', e);
}
},
error: () => {
try {
// Fallback: open dialog with no-content state
this.dialog.open(ClientEditEmailDialogComponent, {
width: '800px',
maxWidth: '96vw',
data: {
emailSubject: null,
emailBody: null,
emailSent: false,
emailSentAt: null,
emailOpenedAt: null,
emailFrom: null,
emailTo: null
}
});
} catch (e: any) {
console.error('Dialog open failed (error): ', e);
}
}
});
}
reload(): void { reload(): void {
this.load(); this.load();
} }
@@ -1,86 +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-card">
<div class="pos-section-label">Share Withdrawal Requests — Review, Approve or Reject</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th class="num">Shares</th>
<th>Resolution No.</th>
<th>Meeting Date</th>
<th>Clerk</th>
<th>Manager</th>
<th>Submitted At</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="10" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="10" class="pg-empty">No {{ filterStatus | lowercase }} share withdrawal requests.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td class="num">{{ r.requested_shares }}</td>
<td>{{ r.resolution_number }}</td>
<td>{{ formatDateTime(r.meeting_date) }}</td>
<td>{{ r.clerk_name }}</td>
<td>{{ r.manager_name || r.manager_username || '—' }}</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="review(r)">Review</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
@if (showReviewModal && reviewRequestId) {
<div class="swr-list-popup-backdrop" (click)="closeReviewModal()">
<div class="swr-list-popup-panel" (click)="$event.stopPropagation()">
<mifosx-share-withdrawal-request
[embedded]="true"
[overrideMode]="'review'"
[overrideRequestId]="reviewRequestId"
(closeRequested)="closeReviewModal()"
(actionCompleted)="onReviewActionCompleted()"
></mifosx-share-withdrawal-request>
</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/.
*/
@keyframes swr-list-popup-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes swr-list-popup-zoom {
from {
opacity: 0;
transform: scale(0.97) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.swr-list-popup-backdrop {
position: fixed;
inset: 0;
// No backdrop-filter here: filter/backdrop-filter on an ancestor makes browsers treat it as a new
// containing block for `position: fixed` descendants — the embedded review screen inside this popup
// renders its OWN fixed-position dialogs (Validate, Reject, Confirm-Submit), which would then be
// confined to this backdrop's box instead of the real viewport. Extra opacity on the background
// color compensates for the loss of the blur look.
background: rgb(15 25 45 / 65%);
z-index: 1100;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 20px;
overflow-y: auto;
animation: swr-list-popup-fade 0.2s ease-out;
}
.swr-list-popup-panel {
background: #f7f9fc;
border-radius: 16px;
width: 100%;
max-width: 1100px;
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
animation: swr-list-popup-zoom 0.25s ease-out;
padding: 4px;
}
@media (prefers-reduced-motion: reduce) {
.swr-list-popup-backdrop,
.swr-list-popup-panel {
animation: none;
}
}
@@ -1,128 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ClerkShareWithdrawalService } from '../../../cashier/clerk-share-withdrawal.service';
import { ShareWithdrawalRequestComponent } from '../../../clerk/share-withdrawal-request/share-withdrawal-request.component';
/** Approval Center — Share Withdrawal Requests tab. A Bank Manager (checker) reviews, approves
* or rejects share withdrawal requests submitted from the Clerk Dashboard. Mirrors
* WithdrawalRequestsComponent (cash withdrawal), adapted for shares. Clicking "Review" opens the
* review screen in a popup on top of this list, rather than navigating away, so approving/rejecting
* drops the manager straight back into the (now refreshed) list. */
@Component({
selector: 'mifosx-share-withdrawal-requests',
templateUrl: './share-withdrawal-requests.component.html',
styleUrls: [
'../../../cashier/cashier-shared.scss',
'../../../clerk/clerk-shared.scss',
'./share-withdrawal-requests.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
ShareWithdrawalRequestComponent
]
})
export class ShareWithdrawalRequestsComponent implements OnInit {
private service = inject(ClerkShareWithdrawalService);
requests: any[] = [];
totalRecords = 0;
loading = false;
loaded = false;
filterStatus = 'PENDING';
showReviewModal = false;
reviewRequestId: number | null = null;
get statusOptions(): { value: string; label: string }[] {
return [
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getPendingRequests(this.filterStatus, 0, 50)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
review(req: any): void {
this.reviewRequestId = req.id;
this.showReviewModal = true;
}
closeReviewModal(): void {
this.showReviewModal = false;
this.reviewRequestId = null;
}
onReviewActionCompleted(): void {
this.closeReviewModal();
this.load();
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off'
};
return `inv-badge ${map[status] ?? ''}`;
}
statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: 'Pending',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return map[status] ?? status;
}
formatDateTime(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return String(val);
}
}
}
@@ -20,207 +20,143 @@
</div> </div>
</div> </div>
<!-- Pill-style tab navigation --> <!-- Pill-style tab navigation — wraps onto multiple rows so every section stays visible and
<nav mat-tab-nav-bar class="ac-tabs" [tabPanel]="tabPanel"> clickable at once, instead of being hidden behind prev/next scroll arrows. -->
<nav class="ac-tabs">
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./checker-inbox']" routerLink="./checker-inbox"
routerLinkActive routerLinkActive="ac-tab--active"
#checkerInbox="routerLinkActive"
[active]="checkerInbox.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon> <fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
<span>Checker Inbox</span> <span>Checker Inbox</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./client-approval']" routerLink="./client-approval"
routerLinkActive routerLinkActive="ac-tab--active"
#clientApproval="routerLinkActive"
[active]="clientApproval.isActive"
*mifosxHasPermission="'READ_CLIENT'" *mifosxHasPermission="'READ_CLIENT'"
> >
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
<span>Client Approval</span> <span>Client Approval</span>
</a> </a>
<a <a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
mat-tab-link
class="ac-tab"
[routerLink]="['./loan-approval']"
routerLinkActive
#loanApproval="routerLinkActive"
[active]="loanApproval.isActive"
*mifosxHasPermission="'READ_LOAN'"
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Loan Approval</span> <span>Loan Approval</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./loan-disbursal']" routerLink="./loan-disbursal"
routerLinkActive routerLinkActive="ac-tab--active"
#loanDisbursal="routerLinkActive"
[active]="loanDisbursal.isActive"
*mifosxHasPermission="'READ_LOAN'" *mifosxHasPermission="'READ_LOAN'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Loan Disbursal</span> <span>Loan Disbursal</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./reschedule-loan']" routerLink="./reschedule-loan"
routerLinkActive routerLinkActive="ac-tab--active"
#rescheduleLoan="routerLinkActive"
[active]="rescheduleLoan.isActive"
*mifosxHasPermission="'RESCHEDULE_LOAN'" *mifosxHasPermission="'RESCHEDULE_LOAN'"
> >
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon> <fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
<span>Reschedule Loan</span> <span>Reschedule Loan</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-account']" routerLink="./pending-account"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingAccount="routerLinkActive"
[active]="pendingAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon> <fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span> <span>Pending Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-savings-products']" routerLink="./pending-savings-products"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingSavingsProducts="routerLinkActive"
[active]="pendingSavingsProducts.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon> <fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Pending Product Edits</span> <span>Pending Product Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-savings-account-edits']" routerLink="./pending-savings-account-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingSavingsAccountEdits="routerLinkActive"
[active]="pendingSavingsAccountEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span> <span>Pending Account Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-share-account-edits']" routerLink="./pending-share-account-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingShareAccountEdits="routerLinkActive"
[active]="pendingShareAccountEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Share Account Edits</span> <span>Pending Share Account Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-client-edits']" routerLink="./pending-client-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingClientEdits="routerLinkActive"
[active]="pendingClientEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Client Edits</span> <span>Pending Client Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./rejected-account']" routerLink="./rejected-account"
routerLinkActive routerLinkActive="ac-tab--active"
#rejectedAccount="routerLinkActive"
[active]="rejectedAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon> <fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>Reject Account</span> <span>Reject Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./terminated-account']" routerLink="./terminated-account"
routerLinkActive routerLinkActive="ac-tab--active"
#terminatedAccount="routerLinkActive"
[active]="terminatedAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon> <fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon>
<span>Terminated Account</span> <span>Terminated Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./withdrawal-requests']" routerLink="./withdrawal-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#withdrawalRequests="routerLinkActive"
[active]="withdrawalRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span> <span>Withdrawal Requests</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./share-withdrawal-requests']" routerLink="./deposit-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#shareWithdrawalRequests="routerLinkActive"
[active]="shareWithdrawalRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Share Withdrawal Requests</span>
</a>
<a
mat-tab-link
class="ac-tab"
[routerLink]="['./deposit-requests']"
routerLinkActive
#depositRequests="routerLinkActive"
[active]="depositRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Deposit Requests</span> <span>Deposit Requests</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./disbursement-requests']" routerLink="./disbursement-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#disbursementRequests="routerLinkActive"
[active]="disbursementRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Disbursement & Transfers</span> <span>Disbursement & Transfers</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./chequebook-requests']" routerLink="./chequebook-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#chequebookRequests="routerLinkActive"
[active]="chequebookRequests.isActive"
*mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'" *mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'"
> >
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
@@ -228,7 +164,7 @@
</a> </a>
</nav> </nav>
<mat-tab-nav-panel #tabPanel> <div class="ac-tab-panel">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </div>
</div> </div>
-19
View File
@@ -17,7 +17,6 @@ import { Route } from '../core/route/route.service';
import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component'; import { CheckerInboxAndTasksComponent } from './checker-inbox-and-tasks/checker-inbox-and-tasks.component';
import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component'; import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-inbox/checker-inbox.component';
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component'; import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
import { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component';
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component'; import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component'; import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component'; import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
@@ -160,11 +159,6 @@ const routes: Routes = [
component: WithdrawalRequestsComponent, component: WithdrawalRequestsComponent,
data: { title: 'Withdrawal Requests' } data: { title: 'Withdrawal Requests' }
}, },
{
path: 'share-withdrawal-requests',
component: ShareWithdrawalRequestsComponent,
data: { title: 'Share Withdrawal Requests' }
},
{ {
path: 'deposit-requests', path: 'deposit-requests',
component: DepositRequestsComponent, component: DepositRequestsComponent,
@@ -206,19 +200,6 @@ const routes: Routes = [
} }
] ]
}, },
{
path: 'checker-inbox-and-tasks/share-withdrawal-requests',
children: [
{
path: ':id/review',
loadComponent: () =>
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
(m) => m.ShareWithdrawalRequestComponent
),
data: { title: 'Review Share Withdrawal Request', breadcrumb: 'Review Share Withdrawal', mode: 'review' }
}
]
},
{ {
path: 'checker-inbox-and-tasks/disbursement-requests', path: 'checker-inbox-and-tasks/disbursement-requests',
children: [ children: [
@@ -1,325 +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="tdsr">
<div class="tdsr__head">
<div class="tdsr__head-l">
<fa-icon icon="percentage" class="tdsr__head-ic"></fa-icon>
<div>
<h2 class="tdsr__title">Deposit Interest &amp; TDS</h2>
<div class="tdsr__sub">
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
{{ scopeLabel }}
</div>
</div>
</div>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
<!-- Summary -->
<div class="tdsr__kpis">
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on FD / RD</div>
</div>
<div class="tdsr__kpi tdsr__kpi--muted">
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS payable</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
<div class="tdsr__kpi-l">Crossed the threshold</div>
</div>
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
</button>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
</div>
@if (summary.noPan > 0) {
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
</div>
}
</div>
<!-- Quarter strip -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
<div class="tdsr__qstrip">
@for (q of summary.byQuarter; track q.quarter) {
<div class="tdsr__qcell">
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
</div>
}
</div>
</div>
<!-- Filters -->
<div class="tdsr__filters">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="filter.fy">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
@if (canSelectBranch) {
<label class="tdsr__filter">
<span>Branch</span>
<select [(ngModel)]="filter.branchCode">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
}
<label class="tdsr__filter">
<span>Quarter</span>
<select [(ngModel)]="filter.quarter">
<option [ngValue]="0">Whole year</option>
@for (q of quarters; track q) {
<option [ngValue]="q">{{ quarterLabel[q] }}</option>
}
</select>
</label>
<label class="tdsr__filter">
<span>Threshold band</span>
<select [(ngModel)]="filter.band">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<input type="text" [(ngModel)]="filter.search" placeholder="name, CIF or PAN" />
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" />
<span>Threshold crossed only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" />
<span>Near threshold only</span>
</label>
@if (hasFilters) {
<button type="button" class="tdsr__clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="tdsr__card">
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Branch</th>
<th>Form · Band</th>
<th class="num">Q1</th>
<th class="num">Q2</th>
<th class="num">Q3</th>
<th class="num">Q4</th>
<th class="num">Year total</th>
<th class="num">Threshold</th>
<th>Crossed</th>
<th class="num">TDS</th>
<th class="num">Saved</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
<div class="mono small muted">
{{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
{{ row.deductee.pan }}
} @else {
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
}
</div>
</td>
<td class="small">{{ branchName(row.branchCode) }}</td>
<td>
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
{{ row.form === '121' ? '121' : row.form }}
</span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
@if (row.hasDeclaration) {
<div class="mono small muted">{{ row.declarationUin }}</div>
}
</td>
@for (q of quarters; track q) {
<td class="num small">
@if (row.quarters[q]) {
₹{{ row.quarters[q] | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
}
</td>
}
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
}
</td>
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
<td class="small">
@if (row.crossedInQuarter) {
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
} @else if (row.nearThreshold) {
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.schedule.totalTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.tdsSaved) {
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
</td>
</tr>
@if (isExpanded(row)) {
<tr class="tdsr__row--detail">
<td colspan="13">
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
is a joint holder but not the first-named one. Tax there is deducted against the first-named
holder, so it is excluded from the figures above.
</div>
</div>
}
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
<div class="tdsr__meta">
<span
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
the deposit product)</span
>
@if (row.declarationAcceptedOn) {
<span
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
>
}
@if (row.paidWithoutDeduction) {
<span>
Paid without deduction:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
{{ statute.statement }}
</span>
}
</div>
</td>
</tr>
}
}
@if (rows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No deductees match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -1,250 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_FYS } from './tds-reports-data';
import {
NEAR_THRESHOLD_BAND,
TdsInterestRow,
TdsInterestSummary,
TdsReportFilter,
tdsInterestRows,
tdsInterestSummary
} from './tds-reports.compute';
@Component({
selector: 'mifosx-tds-interest-report',
templateUrl: './tds-interest-report.component.html',
styleUrls: ['./tds-reports.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
readonly financialYears = TDS_FYS;
readonly quarterLabel = QUARTER_LABEL;
readonly nearBand = NEAR_THRESHOLD_BAND;
readonly quarters: Quarter[] = [
1,
2,
3,
4
];
filter: TdsReportFilter = {
fy: TDS_FYS[0],
branchCode: '',
quarter: 0,
band: '',
search: '',
crossedOnly: false,
nearThresholdOnly: false
};
/** Which customer's quarter-wise detail is open. */
expandedId: string | null = null;
exporting = false;
ngOnInit(): void {
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
}
}
/**
* Provision and return references for the selected year. The charge moved from
* section 194A to section 393(1) on 01 April 2026 and the quarterly return
* became Form 140, so the labels are read from the config rather than written
* into the template.
*/
get statute(): { section: string; statement: string; frequency: string } {
const cfg = FY_TAX_CONFIG[this.filter.fy];
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeLabel(): string {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
}
get rows(): TdsInterestRow[] {
return tdsInterestRows(this.filter);
}
get summary(): TdsInterestSummary {
return tdsInterestSummary(this.filter);
}
get hasFilters(): boolean {
return !!(
this.filter.search ||
this.filter.quarter ||
this.filter.band ||
this.filter.crossedOnly ||
this.filter.nearThresholdOnly
);
}
clearFilters(): void {
this.filter = {
...this.filter,
quarter: 0,
band: '',
search: '',
crossedOnly: false,
nearThresholdOnly: false
};
}
/** Jump straight to the customers worth chasing a declaration from. */
showNearThreshold(): void {
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
}
toggleExpanded(row: TdsInterestRow): void {
this.expandedId = this.expandedId === row.deductee.customerId ? null : row.deductee.customerId;
}
isExpanded(row: TdsInterestRow): boolean {
return this.expandedId === row.deductee.customerId;
}
branchName(code: string): string {
return glBranchName(code);
}
trackRow(_: number, row: TdsInterestRow): string {
return row.deductee.customerId;
}
async exportWorkbook(): Promise<void> {
this.exporting = true;
try {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
ws.addRow([`Scope: ${this.scopeLabel}`]);
ws.addRow([]);
ws.addRow([
'Customer',
'CIF',
'PAN',
'Branch',
'Form',
'Band',
'Age',
'Q1',
'Q2',
'Q3',
'Q4',
'Interest (FD/RD)',
'Savings interest (exempt)',
'Threshold',
'Crossed in',
'Rate %',
'TDS payable',
'Declaration',
'UIN',
'TDS saved',
'Paid without deduction'
]);
for (const r of this.rows) {
ws.addRow([
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
this.branchName(r.branchCode),
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
r.quarters[1],
r.quarters[2],
r.quarters[3],
r.quarters[4],
r.tdsApplicableInterest,
r.exemptInterest,
r.threshold,
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
r.rateApplied * 100,
r.schedule.totalTds,
r.hasDeclaration ? 'Yes' : 'No',
r.declarationUin ?? '',
r.tdsSaved,
r.paidWithoutDeduction
]);
}
const s = this.summary;
ws.addRow([]);
ws.addRow([
'TOTAL',
`${s.deductees} deductees`,
'',
'',
'',
'',
'',
s.byQuarter[0].interest,
s.byQuarter[1].interest,
s.byQuarter[2].interest,
s.byQuarter[3].interest,
s.applicableInterest,
s.exemptInterest,
'',
'',
'',
s.totalTds,
`${s.declarations} filed`,
'',
s.tdsSaved,
s.paidWithoutDeduction
]);
ws.getRow(1).font = { bold: true, size: 13 };
ws.getRow(4).font = { bold: true };
ws.columns.forEach((c) => (c.width = 18));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
this.exporting = false;
}
}
}
@@ -1,237 +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="tdsr">
<div class="tdsr__head">
<div class="tdsr__head-l">
<fa-icon icon="building-columns" class="tdsr__head-ic"></fa-icon>
<div>
<h2 class="tdsr__title">TDS Remitted to Government</h2>
<div class="tdsr__sub">
Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at
{{ asAt | date: 'd MMM y' }}
</div>
</div>
</div>
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
summary.totalDeposited | number: '1.0-0'
}}
deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
</div>
</div>
} @else {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
return.
</div>
</div>
}
@if (summary.overdueChallans > 0) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>{{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.</strong>
Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
</div>
</div>
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Total TDS deducted</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Deposited with Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="totalDefaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ totalDefaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on late deposit</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees in the return</div>
</div>
</div>
<!-- Part A: challans -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — challan-wise remittance</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Amount</th>
<th>Due date</th>
<th>Deposited on</th>
<th>Challan · BSR</th>
<th>Status</th>
<th class="num">Late-deposit interest</th>
<th>{{ statute.statement }} due</th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td>
<strong>{{ quarterLabel[row.quarter] }}</strong>
</td>
<td class="small">{{ row.month }}</td>
<td class="num">
<strong>₹{{ row.amount | number: '1.0-0' }}</strong>
</td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="small">
@if (row.depositedOn) {
{{ row.depositedOn | date: 'd MMM y' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="mono small">
@if (row.challanNo) {
{{ row.challanNo }}
<div class="muted">BSR {{ row.bsrCode }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Late by {{ row.delayMonths }}m</span>
} @else if (row.depositedOn) {
<span class="tdsr__pill tdsr__pill--ok">On time</span>
} @else {
<span class="tdsr__pill">Within due date</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
<div class="small muted">1.5% × {{ row.delayMonths }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ row.returnDueDate }}</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="9" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No tax was deducted in this financial year.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
</div>
</div>
<!-- Part B: return summary -->
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Deposited with Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
</div>
}
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
</div>
@@ -1,203 +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 { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT, TDS_FYS } from './tds-reports-data';
import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute';
@Component({
selector: 'mifosx-tds-remittance-report',
templateUrl: './tds-remittance-report.component.html',
styleUrls: ['./tds-reports.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class TdsRemittanceReportComponent {
private scope = inject(BranchScopeService);
readonly financialYears = TDS_FYS;
readonly quarterLabel = QUARTER_LABEL;
readonly asAt = TDS_AS_AT;
fy = TDS_FYS[0];
exporting = false;
/**
* Provision and return references for the selected year. The quarterly return
* became Form 140 under the 2025 Act, so the labels are read from the config
* rather than written into the template.
*/
get statute(): { section: string; statement: string; frequency: string } {
const cfg = FY_TAX_CONFIG[this.fy];
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
get scopeLabel(): string {
return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName;
}
get rows(): TdsRemittanceRow[] {
return tdsRemittanceRows(this.fy, this.asAt);
}
get summary(): Tds26qSummary {
return tds26qSummary(this.fy, this.asAt);
}
/**
* The control that matters. Tax deducted must equal tax deposited plus tax
* still within its due date. Anything else means something has gone missing
* and must be found before the return is filed.
*/
get reconciled(): boolean {
return this.summary.difference === 0;
}
get totalDefaultInterest(): number {
return this.summary.defaultInterest;
}
trackRow(_: number, row: TdsRemittanceRow): string {
return `${row.fy}-${row.quarter}`;
}
async exportWorkbook(): Promise<void> {
this.exporting = true;
try {
const s = this.summary;
const wb = new ExcelJS.Workbook();
const challan = wb.addWorksheet('Challans');
challan.addRow([`TDS remitted to Government — FY ${this.fy}`]);
challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]);
challan.addRow([]);
challan.addRow([
'Quarter',
'Deduction month',
'Amount deducted',
'Due date',
'Deposited on',
'Challan no.',
'BSR code',
'Status',
'Delay (months)',
'Late-deposit interest',
`${this.statute.statement} due`
]);
for (const r of this.rows) {
challan.addRow([
`Q${r.quarter}`,
r.month,
r.amount,
r.dueDate,
r.depositedOn ?? 'PENDING',
r.challanNo ?? '',
r.bsrCode ?? '',
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date',
r.delayMonths || '',
r.defaultInterest || '',
r.returnDueDate
]);
}
challan.getRow(1).font = { bold: true, size: 13 };
challan.getRow(4).font = { bold: true };
challan.columns.forEach((c) => (c.width = 18));
const recon = wb.addWorksheet('Return Summary');
recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]);
recon.addRow([]);
const lines: [string, number | string][] = [
[
'Deductees',
s.deductees
],
[
'Total interest paid on time deposits',
s.totalInterestPaid
],
[
' — with tax deducted',
s.interestWithDeduction
],
[
' — without deduction (declaration on record)',
s.interestWithoutDeduction
],
[
'TDS at standard rate (PAN on record)',
s.tdsAtStandardRate
],
[
'TDS at higher rate (no PAN)',
s.tdsAtHigherRate
],
[
'Total TDS deducted',
s.totalDeducted
],
[
'Total deposited with Government',
s.totalDeposited
],
[
'Still within due date',
s.pendingDeposit
],
[
'DIFFERENCE (must be zero)',
s.difference
],
[
'Interest payable for late deposit',
s.defaultInterest
],
[
'Challans deposited late',
s.lateChallans
],
[
'Challans overdue',
s.overdueChallans
]
];
for (const line of lines) {
recon.addRow(line);
}
recon.getRow(1).font = { bold: true, size: 13 };
recon.columns.forEach((c) => (c.width = 42));
recon.getColumn(2).width = 20;
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TDS-Remittance-FY${this.fy}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
this.exporting = false;
}
}
}
-396
View File
@@ -1,396 +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/.
*/
/**
* Deductees and their deposit interest, for the two TDS reports.
*
* The bank's deduction obligation runs on time deposits, so the seed
* gives each customer a mix of fixed and recurring deposits — and some savings
* accounts, which are outside the section and must stay out of the aggregate.
*
* Everything is generated from a fixed seed so the figures do not move between
* loads, and the challan schedule is deliberately imperfect: one quarter is
* deposited late so the default-interest column has something to show.
*/
import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
import {
Declaration,
Form15Kind,
InterestCredit,
TdsAccountInput
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */
export const TDS_FYS = [
'2026-27',
'2025-26'
];
/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
export const TDS_AS_AT = '2026-08-01';
export interface TdsDeductee {
clientId: number;
customerId: string;
name: string;
pan: string;
dob: string;
branchCode: string;
/** Declarations on record, keyed by financial year. */
declarations: Record<string, Declaration>;
accounts: TdsAccountInput[];
}
/** One month's remittance of deducted tax to the Government. */
export interface TdsChallan {
fy: string;
/** Quarter the deduction belongs to. */
quarter: 1 | 2 | 3 | 4;
/** Month the tax was deducted in, e.g. '2026-06'. */
month: string;
amount: number;
/** Statutory date by which it had to be deposited. */
dueDate: string;
/** When it actually reached the Government — null while still pending. */
depositedOn: string | null;
challanNo: string | null;
bsrCode: string | null;
}
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function pick<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh'
];
/**
* Quarter-end dates for a financial year. Interest is credited on these, which
* is what makes the deduction fall due.
*/
function quarterEnds(fy: string): string[] {
const y = Number(fy.slice(0, 4));
return [
`${y}-06-30`,
`${y}-09-30`,
`${y}-12-31`,
`${y + 1}-03-31`
];
}
/** A well-formed individual PAN — fourth character 'P'. */
function makePan(rand: () => number, surname: string): string {
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
const digits = `${1000 + Math.floor(rand() * 9000)}`;
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
}
let deducteeCache: TdsDeductee[] | null = null;
/**
* The bank's deductees.
*
* The spread is deliberate so both reports have every case in them: customers
* comfortably under the trigger, customers sitting just below it, customers well
* over, senior citizens on the higher threshold, a few with no PAN on record
* (deducted at the 206AA rate), some joint deposits held as second holder, and
* some declarations that only came in mid-year.
*/
export function tdsDeductees(): TdsDeductee[] {
if (deducteeCache) {
return deducteeCache;
}
const rand = lcg(20260801);
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
const out: TdsDeductee[] = [];
for (let i = 0; i < 64; i++) {
const branch = branches[i % branches.length];
const first = pick(rand, FIRST_NAMES);
const last = pick(rand, SURNAMES);
const clientId = 5000 + i;
// A third of the book is senior citizens, which is about right for a
// co-operative bank's deposit base.
const senior = rand() < 0.34;
const birthYear = senior ? 1948 + Math.floor(rand() * 15) : 1972 + Math.floor(rand() * 22);
const dob = `${birthYear}-${`${1 + Math.floor(rand() * 12)}`.padStart(2, '0')}-${`${1 + Math.floor(rand() * 28)}`.padStart(2, '0')}`;
// Every seventeenth customer has no PAN on record, so tax comes off at the
// higher section 206AA rate. Fixed by index rather than drawn at random, so
// the case is always present.
const hasPan = i % 17 !== 3;
// Aim each customer's annual time-deposit interest at a band, so the report
// has a proper spread either side of the trigger.
const draw = rand();
let annualInterest: number;
const limit = senior ? 100_000 : 50_000;
if (draw < 0.3) {
annualInterest = 4_000 + Math.floor(rand() * (limit - 10_000)); // comfortably under
} else if (draw < 0.45) {
annualInterest = limit - 6_000 + Math.floor(rand() * 6_000); // just below the threshold
} else if (draw < 0.8) {
annualInterest = limit + 2_000 + Math.floor(rand() * 40_000); // over
} else {
annualInterest = 110_000 + Math.floor(rand() * 320_000); // well over
}
// Push two of the no-PAN customers over the trigger. Left to chance they can
// all land under it, and then the 20% line of the return summary comes out
// empty — a report line that is never exercised is one nobody has checked.
if (!hasPan && (i === 3 || i === 37)) {
annualInterest = 70_000 + i * 400;
}
const accounts: TdsAccountInput[] = [];
const acctBase = 41_00_000 + i * 137;
// One or two time deposits, split across the year's quarter ends.
const depositCount = rand() < 0.4 ? 2 : 1;
for (let d = 0; d < depositCount; d++) {
const isRd = rand() < 0.3;
const share = depositCount === 1 ? 1 : d === 0 ? 0.6 : 0.4;
const credits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
// The current year is only part-run, so later quarters have no postings.
const ends = quarterEnds(fy).filter((date) => date <= TDS_AS_AT);
if (!ends.length) {
continue;
}
// The prior year ran in full; scale the current year to what has posted.
const perQuarter = Math.round((annualInterest * share) / 4);
for (const date of ends) {
credits.push({ date, amount: perQuarter });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + d}`.padStart(8, '0')}`,
depositTypeCode: isRd ? 'depositAccountType.recurringDeposit' : 'depositAccountType.fixedDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: {
// A fifth of the book is on annual posting, so tax on those settles
// once at year end rather than quarterly.
code: rand() < 0.2 ? 'savingsPostingInterestPeriodType.annual' : 'savingsPostingInterestPeriodType.quarterly'
},
credits
});
}
// Most customers also hold a savings account. Its interest must never reach
// the deduction aggregate — that is the point of including it here.
if (rand() < 0.75) {
const sbCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
sbCredits.push({ date, amount: 900 + Math.floor(rand() * 2_600) });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 50}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.savingsDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: sbCredits
});
}
// One in eight is the second holder on somebody else's deposit. Tax there is
// deducted against the first-named holder, so it must not land on this
// customer's aggregate.
if (rand() < 0.12) {
const jointCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
jointCredits.push({ date, amount: 16_000 });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 70}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'JOINT',
// Somebody else is first-named.
primaryClientId: clientId + 1,
clientId: clientId + 1,
jointHolders: [{ clientId }],
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: jointCredits
});
}
// Declarations. Only worth filing where tax would otherwise be deducted, so
// they cluster on the customers over the trigger.
const declarations: Record<string, Declaration> = {};
if (annualInterest > (senior ? 100_000 : 50_000) && hasPan && rand() < 0.45) {
const kind: Form15Kind = senior ? '15H' : '15G';
for (const fy of TDS_FYS) {
const y = Number(fy.slice(0, 4));
// A quarter of them only came in mid-year, which leaves tax already
// deducted that the branch cannot reverse.
const midYear = rand() < 0.25;
declarations[fy] = {
kind,
acceptedOn: midYear ? `${y}-10-01` : `${y}-04-05`,
fy,
uin: `${branch.code}${kind}${`${i + 1}`.padStart(4, '0')}`
};
}
}
out.push({
clientId,
customerId: `CIF${`${clientId}`.padStart(8, '0')}`,
name: `${first} ${last}`,
pan: hasPan ? makePan(rand, last) : '',
dob,
branchCode: branch.code,
declarations,
accounts
});
}
deducteeCache = out;
return out;
}
/**
* Statutory date by which tax deducted in a given month must be deposited.
*
* The seventh of the following month, except for March, which runs to 30 April.
*/
export function depositDueDate(month: string): string {
const [
y,
m
] = month.split('-').map(Number);
if (m === 3) {
return `${y}-04-30`;
}
const nextY = m === 12 ? y + 1 : y;
const nextM = m === 12 ? 1 : m + 1;
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
}
let challanCache: Record<string, TdsChallan[]> | null = null;
/**
* The remittance schedule, derived from what the engine says was deducted.
*
* Deductions land at each quarter end, so there is one challan per quarter. The
* Q2 challan of the prior year is deliberately deposited a month and a bit late
* so the late-deposit interest column is exercised, and the current year's latest
* quarter is left pending, which is the ordinary state of a live book.
*/
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
if (challanCache) {
return challanCache;
}
const out: Record<string, TdsChallan[]> = {};
for (const fy of TDS_FYS) {
const y = Number(fy.slice(0, 4));
const months: Record<number, string> = {
1: `${y}-06`,
2: `${y}-09`,
3: `${y}-12`,
4: `${y + 1}-03`
};
const rows: TdsChallan[] = [];
for (const q of [
1,
2,
3,
4
] as (1 | 2 | 3 | 4)[]) {
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
if (amount <= 0) {
continue;
}
const month = months[q];
const dueDate = depositDueDate(month);
// Anything falling after the business date has not been deposited yet.
const pending = dueDate > TDS_AS_AT;
const late = fy === '2025-26' && q === 2;
rows.push({
fy,
quarter: q,
month,
amount,
dueDate,
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
bsrCode: pending ? null : '0510308'
});
}
out[fy] = rows;
}
challanCache = out;
return out;
}
@@ -1,301 +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 { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data';
import {
NEAR_THRESHOLD_BAND,
TdsReportFilter,
deductedByQuarter,
tds26qSummary,
tdsInterestRows,
tdsInterestRowsForFy,
tdsInterestSummary,
tdsRemittanceRows
} from './tds-reports.compute';
/** The complete year in the seed — the one a return would be filed for. */
const CLOSED_FY = '2025-26';
function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
return {
fy: CLOSED_FY,
branchCode: '',
quarter: 0,
band: '',
search: '',
crossedOnly: false,
nearThresholdOnly: false,
...overrides
};
}
describe('the seed has every case the reports need to show', () => {
it('produces deductees on both sides of the threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
expect(rows.length).toBeGreaterThan(20);
expect(rows.some((r) => r.crossed)).toBe(true);
expect(rows.some((r) => !r.crossed)).toBe(true);
expect(rows.some((r) => r.nearThreshold)).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => !r.panOnRecord)).toBe(true);
expect(rows.some((r) => r.hasDeclaration)).toBe(true);
});
it('gives senior citizens the higher threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
});
it('deducts at the higher rate wherever PAN is missing', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2);
}
});
});
describe('the reports follow the Act that governed each year', () => {
it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => {
const old = tdsInterestRowsForFy('2025-26');
expect(old.some((r) => r.form === '15G')).toBe(true);
expect(old.some((r) => r.form === '15H')).toBe(true);
expect(old.some((r) => r.form === '121')).toBe(false);
// The two forms merged on 01 April 2026.
const current = tdsInterestRowsForFy('2026-27');
expect(current.every((r) => r.form === '121')).toBe(true);
});
it('keeps the age-based threshold band in both years', () => {
for (const fy of TDS_FYS) {
const rows = tdsInterestRowsForFy(fy);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
}
});
});
describe('savings interest stays out of the 194A aggregate', () => {
it('reports it separately and never inside the applicable interest', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const withSavings = rows.filter((r) => r.exemptInterest > 0);
expect(withSavings.length).toBeGreaterThan(0);
for (const row of withSavings) {
// The applicable figure must equal only the time-deposit interest where
// this customer is the deductee.
const expected = row.deductee.accounts
.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId)
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.tdsApplicableInterest).toBe(expected);
}
});
it('leaves the savings totals out of the summary base', () => {
const s = tdsInterestSummary(filter());
expect(s.exemptInterest).toBeGreaterThan(0);
expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
});
});
describe('joint deposits are attributed to the first-named holder only', () => {
it('excludes second-holder interest from the aggregate and shows it apart', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const secondHolders = rows.filter((r) => r.jointHeldInterest > 0);
expect(secondHolders.length).toBeGreaterThan(0);
for (const row of secondHolders) {
const jointAccounts = row.deductee.accounts.filter(
(a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId
);
expect(jointAccounts.length).toBeGreaterThan(0);
// None of that interest may appear in the figure the threshold is tested
// against, or one deposit would be taxed against two people.
const jointTotal = jointAccounts
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.jointHeldInterest).toBe(jointTotal);
expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal);
}
});
});
describe('quarter figures add up to the year', () => {
it('per row', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4];
expect(quarterInterest).toBe(row.tdsApplicableInterest);
expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds);
}
});
it('and across the summary', () => {
const s = tdsInterestSummary(filter());
expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest);
expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds);
});
});
describe('filters', () => {
it("narrows to a branch without changing anyone's figures", () => {
const all = tdsInterestRows(filter());
const branch = all[0].branchCode;
const scoped = tdsInterestRows(filter({ branchCode: branch }));
expect(scoped.length).toBeGreaterThan(0);
expect(scoped.every((r) => r.branchCode === branch)).toBe(true);
// A branch view must not re-aggregate — the threshold is computed bank-wide.
const sample = scoped[0];
const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId);
expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest);
});
it('finds the customers worth chasing a declaration from', () => {
const near = tdsInterestRows(filter({ nearThresholdOnly: true }));
expect(near.length).toBeGreaterThan(0);
for (const row of near) {
expect(row.crossed).toBe(false);
expect(row.headroom).toBeGreaterThan(0);
expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND);
}
});
it('narrows to customers who crossed', () => {
const crossed = tdsInterestRows(filter({ crossedOnly: true }));
expect(crossed.length).toBeGreaterThan(0);
expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true);
});
it('searches on name, CIF and PAN', () => {
const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!;
expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1);
expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1);
});
});
describe('declarations', () => {
it('save tax and record the interest paid gross', () => {
const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration);
expect(declared.length).toBeGreaterThan(0);
expect(declared.some((r) => r.tdsSaved > 0)).toBe(true);
// Interest paid without deduction is a mandatory 26Q disclosure, so it must
// be carried even though no tax came off it.
expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true);
for (const row of declared) {
expect(row.declarationUin).toBeTruthy();
}
});
it('leaves tax deducted before a mid-year declaration in place', () => {
// Some declarations in the seed were accepted in October. Tax already taken
// in Q1/Q2 cannot be reversed, so those rows still carry a deduction.
const midYear = tdsInterestRowsForFy(CLOSED_FY).filter(
(r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10'
);
expect(midYear.length).toBeGreaterThan(0);
expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true);
});
});
describe('remittance schedule', () => {
it('deposits by the seventh of the following month, and 30 April for March', () => {
expect(depositDueDate('2025-06')).toBe('2025-07-07');
expect(depositDueDate('2025-12')).toBe('2026-01-07');
expect(depositDueDate('2026-03')).toBe('2026-04-30');
});
it('remits exactly what the engine says was deducted', () => {
const deducted = deductedByQuarter(CLOSED_FY);
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
for (const row of rows) {
expect(row.amount).toBe(Math.round(deducted[row.quarter]));
}
});
it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => {
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
const late = rows.filter((r) => r.late);
expect(late.length).toBeGreaterThan(0);
for (const row of late) {
expect(row.delayMonths).toBeGreaterThan(0);
expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths));
}
for (const row of rows.filter((r) => !r.late && !r.overdue)) {
expect(row.defaultInterest).toBe(0);
}
});
});
describe('the 26Q reconciliation control', () => {
it.each(TDS_FYS)('balances for FY %s', (fy) => {
const s = tds26qSummary(fy, TDS_AS_AT);
// Deducted must equal deposited plus what is still within its due date.
// Anything else means tax has gone missing between the ledger and the
// Government, and the return must not be filed until it is found.
expect(s.difference).toBe(0);
expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit);
});
it('splits the tax between the standard and the no-PAN rate without loss', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted);
expect(s.tdsAtHigherRate).toBeGreaterThan(0);
});
it('splits interest into the deducted and the paid-gross halves', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid);
// The gross-paid half is the Form 121 disclosure and must not be zero here,
// or the report would never exercise that line.
expect(s.interestWithoutDeduction).toBeGreaterThan(0);
});
it('agrees with the interest report on the same year', () => {
const summary = tdsInterestSummary(filter());
expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds);
});
});
describe('the part-run current year', () => {
it('has postings only up to the business date', () => {
for (const deductee of tdsDeductees()) {
for (const account of deductee.accounts) {
for (const credit of account.credits) {
expect(credit.date <= TDS_AS_AT).toBe(true);
}
}
}
});
it('shows the closed quarter as settled and future obligations as not yet due', () => {
const rows = tdsRemittanceRows('2026-27', TDS_AS_AT);
// Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul.
expect(rows.some((r) => r.quarter === 1)).toBe(true);
// Q3 cannot appear: no interest has posted there and nothing settles there.
expect(rows.some((r) => r.quarter === 3)).toBe(false);
// Q4 *can* appear even in a part-run year — deposits on annual interest
// posting settle once, at year end — but that is a future obligation, so it
// must read as pending and must never be flagged overdue.
for (const row of rows.filter((r) => r.quarter === 4)) {
expect(row.depositedOn).toBeNull();
expect(row.overdue).toBe(false);
expect(row.defaultInterest).toBe(0);
}
});
});
-395
View File
@@ -1,395 +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/.
*/
/**
* Aggregation for the two TDS reports.
*
* Every rupee figure here comes out of `tds-15gh.compute` — the reports do no
* tax arithmetic of their own. Writing it twice is how the register and the
* return end up disagreeing, so the engine is the single authority and this
* module only groups and totals what it returns.
*/
import {
DeclarationForm,
FY_TAX_CONFIG,
Quarter,
TdsMode,
TdsSchedule,
ageInFy,
buildSchedule,
collectTdsCredits,
declarationFormFor,
formKindFor,
fyOf,
isSeniorInFy,
isTdsApplicable,
isWellFormedPan,
lateDeductionInterest,
monthsOrPartBetween,
tdsDeducteeOf,
tdsModeFor
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data';
// ─────────────────────────────── Filtering ───────────────────────────────
export interface TdsReportFilter {
fy: string;
/** '' means every branch. */
branchCode: string;
/** 0 means the whole year. */
quarter: 0 | Quarter;
/** '' means both declaration types. */
band: '' | 'GENERAL' | 'SENIOR';
/** Free text over name, CIF and PAN. */
search: string;
/** Only customers whose aggregate crossed the deduction trigger. */
crossedOnly: boolean;
/** Only customers sitting just below the trigger — the follow-up list. */
nearThresholdOnly: boolean;
}
/** How close to the trigger counts as "worth chasing a declaration for". */
export const NEAR_THRESHOLD_BAND = 6_000;
// ───────────────────────── Report 1: interest ─────────────────────────
export interface TdsInterestRow {
deductee: TdsDeductee;
branchCode: string;
/** The form in force for the year — '121' from FY 2026-27. */
form: DeclarationForm;
/** Which deduction trigger applies. Age-based, even after the forms merged. */
band: 'GENERAL' | 'SENIOR';
/**
* The 15G/15H label. A form distinction only up to FY 2025-26; from FY 2026-27
* the two merged, so it duplicates `band` and is kept for pre-2026 years.
*/
kind: '15G' | '15H';
age: number;
/** Settlement timing, from the customer's deposit products. */
mode: TdsMode;
/** Interest on time deposits where this customer is the deductee, by quarter. */
quarters: Record<Quarter, number>;
/** Total of the above — the figure the deduction trigger is tested against. */
tdsApplicableInterest: number;
/** Savings interest, shown alongside but outside the charge on time deposits. */
exemptInterest: number;
/** Interest on deposits where somebody else is the first-named holder. */
jointHeldInterest: number;
threshold: number;
crossed: boolean;
/** The quarter the aggregate crossed the trigger, if it did. */
crossedInQuarter: Quarter | null;
/** How much more interest before the trigger bites. */
headroom: number;
nearThreshold: boolean;
schedule: TdsSchedule;
hasDeclaration: boolean;
declarationUin: string | null;
declarationAcceptedOn: string | null;
/** Tax the declaration kept out of the customer's pocket. */
tdsSaved: number;
/** Interest paid gross on the strength of the declaration — reportable in the return. */
paidWithoutDeduction: number;
panOnRecord: boolean;
rateApplied: number;
}
function emptyQuarters(): Record<Quarter, number> {
return { 1: 0, 2: 0, 3: 0, 4: 0 };
}
/**
* One row per deductee for the selected year.
*
* Memoised on the year alone: building a row runs two schedules, and the getters
* that feed the template are read many times per change-detection pass.
*/
let rowCacheKey = '';
let rowCache: TdsInterestRow[] = [];
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
if (rowCacheKey === fy) {
return rowCache;
}
const rows: TdsInterestRow[] = [];
for (const deductee of tdsDeductees()) {
const ctx = {
fy,
dateOfBirth: deductee.dob,
pan: deductee.pan,
resident: true
};
// Rules 8 to 12 live in the engine: savings interest dropped, joint deposits
// attributed to the first-named holder, every branch aggregated.
const credits = collectTdsCredits(deductee.clientId, deductee.accounts, fy);
if (!credits.length && !deductee.accounts.length) {
continue;
}
const declaration = deductee.declarations[fy] ?? null;
const mode = overallModeFor(deductee);
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
const withoutDeclaration = declaration ? buildSchedule(mode, credits, { ...ctx, declaration: null }) : schedule;
const quarters = emptyQuarters();
for (const line of schedule.lines) {
quarters[line.quarter] = line.interest;
}
let exemptInterest = 0;
let jointHeldInterest = 0;
for (const account of deductee.accounts) {
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
if (!isTdsApplicable(account)) {
exemptInterest += inYear;
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
jointHeldInterest += inYear;
}
}
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
rows.push({
deductee,
branchCode: deductee.branchCode,
form: declarationFormFor(fy, deductee.dob),
band: isSeniorInFy(deductee.dob, fy) ? 'SENIOR' : 'GENERAL',
kind: formKindFor(deductee.dob, fy),
age: ageInFy(deductee.dob, fy),
mode,
quarters,
tdsApplicableInterest: schedule.totalInterest,
exemptInterest,
jointHeldInterest,
threshold: schedule.threshold,
crossed: schedule.totalInterest > schedule.threshold,
crossedInQuarter: crossedLine?.quarter ?? null,
headroom,
nearThreshold: headroom > 0 && headroom <= NEAR_THRESHOLD_BAND,
schedule,
hasDeclaration: !!declaration,
declarationUin: declaration?.uin ?? null,
declarationAcceptedOn: declaration?.acceptedOn ?? null,
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
paidWithoutDeduction: schedule.shieldedInterest,
panOnRecord: isWellFormedPan(deductee.pan),
rateApplied: schedule.rateApplied
});
}
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
rowCacheKey = fy;
rowCache = rows;
return rows;
}
/**
* When tax settles for a customer whose deposits post interest on different
* cycles. A quarterly deposit anywhere means a deduction falls due at the first
* quarter end, so that governs.
*/
function overallModeFor(deductee: TdsDeductee): TdsMode {
const applicable = deductee.accounts.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === deductee.clientId);
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
const needle = filter.search.trim().toLowerCase();
return tdsInterestRowsForFy(filter.fy).filter((row) => {
if (filter.branchCode && row.branchCode !== filter.branchCode) {
return false;
}
if (filter.band && row.band !== filter.band) {
return false;
}
if (filter.crossedOnly && !row.crossed) {
return false;
}
if (filter.nearThresholdOnly && !row.nearThreshold) {
return false;
}
// A quarter filter narrows to customers who actually had interest in it.
if (filter.quarter && !row.quarters[filter.quarter]) {
return false;
}
if (!needle) {
return true;
}
const d = row.deductee;
return (
d.name.toLowerCase().includes(needle) ||
d.customerId.toLowerCase().includes(needle) ||
d.pan.toLowerCase().includes(needle)
);
});
}
export interface TdsInterestSummary {
deductees: number;
/** Interest on time deposits — the base the charge applies to. */
applicableInterest: number;
/** Savings interest, outside the section. */
exemptInterest: number;
totalTds: number;
crossed: number;
nearThreshold: number;
declarations: number;
tdsSaved: number;
paidWithoutDeduction: number;
noPan: number;
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
}
export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary {
const rows = tdsInterestRows(filter);
const byQuarter = ([
1,
2,
3,
4
] as Quarter[]).map((quarter) => ({
quarter,
interest: rows.reduce((s, r) => s + r.quarters[quarter], 0),
tds: rows.reduce((s, r) => s + (r.schedule.lines.find((l) => l.quarter === quarter)?.tds ?? 0), 0)
}));
return {
deductees: rows.length,
applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0),
totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0),
crossed: rows.filter((r) => r.crossed).length,
nearThreshold: rows.filter((r) => r.nearThreshold).length,
declarations: rows.filter((r) => r.hasDeclaration).length,
tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0),
paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
noPan: rows.filter((r) => !r.panOnRecord).length,
byQuarter
};
}
// ─────────────────────── Report 2: remittance ───────────────────────
export interface TdsRemittanceRow extends TdsChallan {
/** Statutory due date has passed with nothing deposited. */
overdue: boolean;
/** Deposited, but after the due date. */
late: boolean;
/** Months or part months of delay. */
delayMonths: number;
/** Interest charged for depositing late. */
defaultInterest: number;
/** Return the quarter's deduction is reported in. */
returnDueDate: string;
}
const RETURN_DUE: Record<Quarter, string> = {
1: '31 Jul',
2: '31 Oct',
3: '31 Jan',
4: '31 May'
};
/** Tax deducted per quarter, from the engine, for one year. */
export function deductedByQuarter(fy: string): Record<number, number> {
const out: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0 };
for (const row of tdsInterestRowsForFy(fy)) {
for (const line of row.schedule.lines) {
out[line.quarter] += line.tds;
}
}
return out;
}
export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] {
const deducted: Record<string, Record<number, number>> = {};
for (const year of Object.keys(FY_TAX_CONFIG)) {
deducted[year] = deductedByQuarter(year);
}
const challans = tdsChallans(deducted)[fy] ?? [];
return challans.map((c) => {
const overdue = !c.depositedOn && c.dueDate < asAt;
const late = !!c.depositedOn && c.depositedOn > c.dueDate;
// While a payment is overdue the clock runs to today; once paid it stops on
// the payment date.
const settledOn = c.depositedOn ?? asAt;
const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0;
const defaultInterest =
late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0;
return {
...c,
overdue,
late,
delayMonths,
defaultInterest,
returnDueDate: RETURN_DUE[c.quarter]
};
});
}
/**
* The year-end position — what the quarterly TDS return has to reconcile to.
*
* `difference` is the control figure: tax the engine says was deducted, less what
* actually reached the Government. Anything other than zero, once the pending
* challans are settled, means something is wrong and must be found before filing.
*/
export interface Tds26qSummary {
fy: string;
deductees: number;
totalInterestPaid: number;
interestWithDeduction: number;
/** Paid gross against a declaration — a mandatory disclosure in the return. */
interestWithoutDeduction: number;
tdsAtStandardRate: number;
tdsAtHigherRate: number;
totalDeducted: number;
totalDeposited: number;
pendingDeposit: number;
/** Deducted less deposited less still-pending. Must be zero. */
difference: number;
defaultInterest: number;
lateChallans: number;
overdueChallans: number;
}
export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
const rows = tdsInterestRowsForFy(fy);
const challans = tdsRemittanceRows(fy, asAt);
const standard = rows.filter((r) => r.panOnRecord);
const higher = rows.filter((r) => !r.panOnRecord);
const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0);
const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0);
const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0);
return {
fy,
deductees: rows.length,
totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0),
interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0),
tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0),
totalDeducted,
totalDeposited,
pendingDeposit,
difference: Math.round(totalDeducted - totalDeposited - pendingDeposit),
defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0),
lateChallans: challans.filter((c) => c.late).length,
overdueChallans: challans.filter((c) => c.overdue).length
};
}
-546
View File
@@ -1,546 +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/.
*/
/* Shared by the interest report and the remittance report. */
.tdsr {
--tdsr-line: #e5e7eb;
--tdsr-bg: #f9fafb;
--tdsr-ink: #111827;
--tdsr-muted: #6b7280;
--tdsr-blue: #1d4ed8;
--tdsr-green: #15803d;
--tdsr-amber: #b45309;
padding: 20px 24px 40px;
font-size: 13px;
color: var(--tdsr-ink);
}
/* ── Header ── */
.tdsr__head {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
}
.tdsr__head-l {
display: flex;
gap: 14px;
align-items: flex-start;
}
.tdsr__head-r {
display: flex;
gap: 12px;
align-items: flex-end;
}
.tdsr__head-ic {
font-size: 22px;
color: var(--tdsr-blue);
margin-top: 3px;
}
.tdsr__title {
margin: 0;
font-size: 19px;
font-weight: 800;
}
.tdsr__sub {
margin-top: 4px;
color: var(--tdsr-muted);
font-size: 12px;
line-height: 1.5;
}
.tdsr__btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 15px;
border: 1px solid var(--tdsr-line);
border-radius: 7px;
background: #fff;
font-size: 12.5px;
font-weight: 700;
color: var(--tdsr-ink);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--tdsr-bg);
}
&:disabled {
opacity: 0.55;
cursor: default;
}
}
/* ── Banners ── */
.tdsr__banner {
display: flex;
gap: 11px;
align-items: flex-start;
margin-bottom: 14px;
padding: 13px 15px;
border-radius: 9px;
border-left: 4px solid var(--tdsr-blue);
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
line-height: 1.55;
fa-icon {
flex-shrink: 0;
margin-top: 1px;
color: var(--tdsr-blue);
}
&--ok {
border-left-color: var(--tdsr-green);
background: color-mix(in srgb, var(--tdsr-green) 7%, transparent);
fa-icon {
color: var(--tdsr-green);
}
}
&--warn {
border-left-color: var(--tdsr-amber);
background: color-mix(in srgb, var(--tdsr-amber) 9%, transparent);
fa-icon {
color: var(--tdsr-amber);
}
}
}
/* ── KPI strip ── */
.tdsr__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.tdsr__kpi {
padding: 13px 15px;
border: 1px solid var(--tdsr-line);
border-left: 3px solid var(--tdsr-blue);
border-radius: 9px;
background: #fff;
text-align: left;
&--ok {
border-left-color: var(--tdsr-green);
}
&--warn {
border-left-color: var(--tdsr-amber);
}
&--muted {
border-left-color: var(--tdsr-line);
}
/* The near-threshold tile doubles as the button that filters to it. */
&--action {
border-left-color: var(--tdsr-green);
cursor: pointer;
font: inherit;
color: inherit;
&:hover {
background: var(--tdsr-bg);
}
}
}
.tdsr__kpi-n {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.tdsr__kpi-l {
margin-top: 3px;
font-size: 11px;
color: var(--tdsr-muted);
line-height: 1.4;
}
/* ── Filters ── */
.tdsr__filters {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-end;
margin-bottom: 16px;
padding: 14px 16px;
border: 1px solid var(--tdsr-line);
border-radius: 9px;
background: #fff;
}
.tdsr__filter {
display: flex;
flex-direction: column;
gap: 5px;
> span {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--tdsr-muted);
}
select,
input {
min-width: 150px;
padding: 7px 10px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
font-size: 13px;
color: inherit;
}
&--wide input {
min-width: 230px;
}
}
.tdsr__check {
display: inline-flex;
gap: 7px;
align-items: center;
padding-bottom: 8px;
font-size: 12.5px;
cursor: pointer;
}
.tdsr__clear {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
padding: 7px 12px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: var(--tdsr-bg);
font-size: 12px;
font-weight: 700;
color: var(--tdsr-muted);
cursor: pointer;
}
/* ── Cards ── */
.tdsr__card {
margin-bottom: 18px;
border: 1px solid var(--tdsr-line);
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.tdsr__card-head {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 16px;
background: var(--tdsr-bg);
border-bottom: 1px solid var(--tdsr-line);
font-weight: 800;
font-size: 12.5px;
fa-icon {
color: var(--tdsr-muted);
}
}
.tdsr__foot {
padding: 11px 16px;
border-top: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
color: var(--tdsr-muted);
font-size: 11.5px;
line-height: 1.55;
}
.tdsr__empty {
padding: 26px 16px;
text-align: center;
color: var(--tdsr-muted);
fa-icon {
margin-right: 6px;
color: var(--tdsr-blue);
}
}
/* ── Quarter strip ── */
.tdsr__qstrip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.tdsr__qcell {
padding: 14px 16px;
border-right: 1px solid var(--tdsr-line);
&:last-child {
border-right: 0;
}
}
.tdsr__qcell-l {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--tdsr-muted);
}
.tdsr__qcell-n {
margin-top: 4px;
font-size: 17px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.tdsr__qcell-s {
margin-top: 2px;
font-size: 11.5px;
color: var(--tdsr-muted);
}
/* ── Tables ── */
.tdsr__table-wrap {
overflow-x: auto;
}
.tdsr__table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 11px 14px;
background: #fff;
border-bottom: 1px solid var(--tdsr-line);
color: var(--tdsr-muted);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
td {
padding: 11px 14px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.small {
font-size: 11.5px;
}
.muted {
color: var(--tdsr-muted);
}
.col-actions {
width: 44px;
text-align: right;
}
&--inner {
border: 1px solid var(--tdsr-line);
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
}
}
.tdsr__row--warn > td {
background: color-mix(in srgb, var(--tdsr-amber) 7%, transparent);
}
.tdsr__row--near > td {
background: color-mix(in srgb, var(--tdsr-green) 5%, transparent);
}
.tdsr__row--detail > td {
background: var(--tdsr-bg);
padding: 16px;
}
.tdsr__name {
font-weight: 700;
}
.tdsr__ok {
color: var(--tdsr-green);
}
.tdsr__warn-text {
color: var(--tdsr-amber);
}
.tdsr__icon-btn {
padding: 5px 8px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
color: var(--tdsr-muted);
cursor: pointer;
&:hover {
background: var(--tdsr-bg);
}
}
.tdsr__note {
display: flex;
gap: 9px;
align-items: flex-start;
margin-bottom: 11px;
padding: 10px 12px;
border-radius: 8px;
border-left: 3px solid var(--tdsr-blue);
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
font-size: 12px;
line-height: 1.5;
fa-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--tdsr-blue);
}
}
.tdsr__meta {
display: flex;
flex-wrap: wrap;
gap: 18px;
font-size: 11.5px;
color: var(--tdsr-muted);
}
/* ── Ledger (Part B of the remittance report) ── */
.tdsr__ledger-row {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 11px 16px;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
&:last-child {
border-bottom: 0;
}
.num {
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 600;
}
em {
display: block;
font-size: 10.5px;
color: var(--tdsr-amber);
font-style: normal;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&--indent {
padding-left: 36px;
color: var(--tdsr-muted);
}
&--sep {
border-top: 1px solid var(--tdsr-line);
}
&--total {
border-top: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
font-weight: 800;
}
/* The reconciliation control. Green when zero, red when not. */
&--control {
background: color-mix(in srgb, var(--tdsr-green) 8%, transparent);
font-weight: 800;
color: var(--tdsr-green);
}
&--bad {
background: color-mix(in srgb, var(--tdsr-amber) 10%, transparent);
color: var(--tdsr-amber);
}
}
/* ── Pills ── */
.tdsr__pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
color: var(--tdsr-muted);
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
& + & {
margin-left: 5px;
}
&--ok {
border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
background: color-mix(in srgb, var(--tdsr-green) 10%, transparent);
color: var(--tdsr-green);
}
&--warn {
border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
background: color-mix(in srgb, var(--tdsr-amber) 12%, transparent);
color: var(--tdsr-amber);
}
&--near {
border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
background: color-mix(in srgb, var(--tdsr-blue) 8%, transparent);
color: var(--tdsr-blue);
}
}
@@ -1,467 +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/.
*/
/**
* The three things the bank actually has to file or hand over.
*
* 1. The consolidated statement of declarations received — monthly from
* FY 2026-27, due on the 7th of the following month, carrying the UIN the
* branch allotted to each Form 121.
* 2. The deductee annexure of the quarterly return (Form 140), which needs the
* UIN against every payee paid gross so the department can tie a
* nil-deduction back to a declaration.
* 3. The certificate issued to each deductee (Form 131), which is what the
* customer uses to claim credit in their own return.
*
* All figures come from the TDS engine. Nothing here recomputes tax — a return
* that disagrees with the register is worse than no return at all.
*/
import {
FY_TAX_CONFIG,
Quarter,
buildUin,
declarationStatementDueDate,
isWellFormedPan,
normaliseFy,
quarterEnd,
quarterOf
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT } from './tds-reports-data';
import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute';
/** The bank's own deductor particulars, which head every filing. */
export interface DeductorParticulars {
tan: string;
pan: string;
name: string;
branchAddress: string;
responsiblePerson: string;
designation: string;
}
/**
* Placeholder particulars for the demo book. In a live install these come from
* the organisation's tax registration, not from a constant.
*/
export const DEDUCTOR: DeductorParticulars = {
tan: 'MUMC12345E',
pan: 'AAACC1234C',
name: 'CBS Nexora Co-operative Bank Ltd.',
branchAddress: 'Head Office, Mumbai 400001',
responsiblePerson: 'Chief Financial Officer',
designation: 'Principal Officer'
};
// ───────────────── 1. Consolidated statement of declarations ─────────────────
export interface DeclarationStatementLine {
/** Running number within the statement. */
serial: number;
uin: string;
payeeName: string;
pan: string;
customerId: string;
branchCode: string;
/** Date the branch accepted the declaration. */
acceptedOn: string;
/**
* Income the declaration covers, as estimated when it was accepted. The
* statement reports the amount the payer expects to pay gross.
*/
estimatedIncome: number;
/** Interest actually paid without deduction so far. */
paidWithoutDeduction: number;
/** Whether the declaration would survive scrutiny on the branch's own record. */
eligible: boolean;
}
export interface DeclarationStatementPeriod {
/** 'MONTHLY' periods are keyed '2026-04'; quarterly ones 'Q1'. */
key: string;
label: string;
/** Statutory upload deadline for this period. */
dueDate: string;
overdue: boolean;
lines: DeclarationStatementLine[];
totalPaidWithoutDeduction: number;
}
export interface DeclarationStatement {
fy: string;
form: string;
rule: string;
frequency: 'MONTHLY' | 'QUARTERLY';
periods: DeclarationStatementPeriod[];
totalDeclarations: number;
/** Declarations on record that the branch could not lawfully have accepted. */
ineligibleCount: number;
/** Declarations with no UIN allotted — these cannot be filed at all. */
missingUinCount: number;
}
function monthLabel(key: string): string {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
return `${months[Number(key.slice(5, 7)) - 1]} ${key.slice(0, 4)}`;
}
/**
* Groups every declaration on record for the year into the periods the payer has
* to upload, and flags the ones that are already past their due date.
*
* A declaration with no UIN is counted separately rather than filed with a blank:
* the portal rejects the line, and a silently dropped row is how a bank ends up
* reporting fewer declarations than it holds.
*/
export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
throw new Error(`No tax configuration for FY ${year}`);
}
const declared = tdsInterestRowsForFy(year).filter((r) => r.hasDeclaration && r.declarationAcceptedOn);
const buckets = new Map<string, DeclarationStatementLine[]>();
let serial = 0;
let ineligibleCount = 0;
let missingUinCount = 0;
for (const row of declared) {
const acceptedOn = row.declarationAcceptedOn as string;
const key = cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`;
if (!row.declarationUin) {
missingUinCount++;
}
// The branch cannot compute income earned elsewhere, so eligibility here is
// only what the register can see: a valid PAN on an individual.
const eligible = isWellFormedPan(row.deductee.pan);
if (!eligible) {
ineligibleCount++;
}
const line: DeclarationStatementLine = {
serial: ++serial,
uin: row.declarationUin ?? '',
payeeName: row.deductee.name,
pan: row.deductee.pan,
customerId: row.deductee.customerId,
branchCode: row.branchCode,
acceptedOn,
estimatedIncome: row.tdsApplicableInterest,
paidWithoutDeduction: row.paidWithoutDeduction,
eligible
};
const list = buckets.get(key);
if (list) {
list.push(line);
} else {
buckets.set(key, [line]);
}
}
const periods: DeclarationStatementPeriod[] = [...buckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(
([
key,
lines
]) => {
const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year);
return {
key,
label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key,
dueDate,
overdue: dueDate < asAt,
lines,
totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0)
};
}
);
return {
fy: year,
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
rule: cfg.declarationRule,
frequency: cfg.declarationStatementFrequency,
periods,
totalDeclarations: declared.length,
ineligibleCount,
missingUinCount
};
}
/**
* Allots UINs to declarations that do not have one.
*
* Sequence numbers continue from the highest already in use for the year, so a
* re-run does not renumber declarations that have already been filed.
*/
export function allotMissingUins(rows: TdsInterestRow[], fy: string, tan: string = DEDUCTOR.tan): Map<string, string> {
const out = new Map<string, string>();
let next = rows.reduce((max, r) => {
const seq = r.declarationUin ? Number(r.declarationUin.slice(1, 10)) : 0;
return Number.isFinite(seq) && seq > max ? seq : max;
}, 0);
for (const row of rows) {
if (row.hasDeclaration && !row.declarationUin) {
out.set(row.deductee.customerId, buildUin(++next, fy, tan));
}
}
return out;
}
// ──────────────────── 2. Deductee annexure of the return ────────────────────
/** One deductee line in the quarterly return's annexure. */
export interface ReturnAnnexureLine {
serial: number;
payeeName: string;
pan: string;
/** 'Individual' or 'HUF', from the fourth character of the PAN. */
payeeType: string;
/** Amount paid or credited in the quarter. */
amountPaid: number;
/** Tax deducted in the quarter. */
taxDeducted: number;
rate: number;
/**
* Reason the payer deducted nothing or less than the standard rate.
* Blank where tax was deducted normally.
*/
remark: string;
/** UIN of the declaration, required wherever the remark cites one. */
uin: string;
branchCode: string;
}
export interface ReturnAnnexure {
fy: string;
quarter: Quarter;
form: string;
rule: string;
section: string;
/** Last day of the quarter the return covers. */
periodEnd: string;
dueDate: string;
lines: ReturnAnnexureLine[];
totalPaid: number;
totalDeducted: number;
/** Lines carrying a nil-deduction remark — these need a UIN. */
nilDeductionLines: number;
/** Nil-deduction lines with no UIN. The portal will reject these. */
missingUinLines: number;
}
const RETURN_DUE_DATE: Record<Quarter, (startYear: number) => string> = {
1: (y) => `${y}-07-31`,
2: (y) => `${y}-10-31`,
3: (y) => `${y + 1}-01-31`,
4: (y) => `${y + 1}-05-31`
};
/**
* Remark codes for a nil or lower deduction.
*
* The department's file specification assigns a letter per reason. The letters
* have been revised more than once, so they live here as named constants and
* must be checked against the current specification before a file is generated —
* a wrong code fails validation at upload, or worse, passes and misreports.
*/
export const NIL_DEDUCTION_REMARK = {
/** Payee filed a declaration for receipt without deduction. */
DECLARATION: 'B',
/** Aggregate stayed within the threshold, so nothing was deductible. */
BELOW_THRESHOLD: 'Y',
/** Higher rate applied because PAN was not furnished. */
NO_PAN: 'C'
};
function payeeTypeOf(pan: string): string {
const p = (pan || '').toUpperCase();
if (!isWellFormedPan(p)) {
return 'Unknown';
}
return p[3] === 'H' ? 'HUF' : p[3] === 'P' ? 'Individual' : 'Entity';
}
/**
* The deductee annexure for one quarter.
*
* Every payee who was paid interest in the quarter appears, whether or not tax
* came off — a payee paid gross against a declaration is precisely what the
* department cross-checks, so omitting them is the error to avoid.
*/
export function returnAnnexure(fy: string, quarter: Quarter): ReturnAnnexure {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
throw new Error(`No tax configuration for FY ${year}`);
}
const startYear = Number(year.slice(0, 4));
const lines: ReturnAnnexureLine[] = [];
let serial = 0;
for (const row of tdsInterestRowsForFy(year)) {
const line = row.schedule.lines.find((l) => l.quarter === quarter);
const amountPaid = line?.interest ?? 0;
const taxDeducted = line?.tds ?? 0;
if (amountPaid === 0 && taxDeducted === 0) {
continue;
}
// The remark explains why the deduction is not simply rate × amount.
let remark = '';
if (taxDeducted <= 0 && amountPaid > 0) {
if (row.hasDeclaration && line?.declarationActive) {
remark = NIL_DEDUCTION_REMARK.DECLARATION;
} else if (!row.crossed) {
remark = NIL_DEDUCTION_REMARK.BELOW_THRESHOLD;
}
} else if (taxDeducted > 0 && !row.panOnRecord) {
remark = NIL_DEDUCTION_REMARK.NO_PAN;
}
lines.push({
serial: ++serial,
payeeName: row.deductee.name,
pan: row.deductee.pan,
payeeType: payeeTypeOf(row.deductee.pan),
amountPaid,
taxDeducted,
rate: row.rateApplied,
remark,
uin: row.declarationUin ?? '',
branchCode: row.branchCode
});
}
const nilDeduction = lines.filter((l) => l.remark === NIL_DEDUCTION_REMARK.DECLARATION);
return {
fy: year,
quarter,
form: cfg.tdsStatementForm,
rule: cfg.tdsStatementRule,
section: cfg.interestTdsSection,
periodEnd: quarterEnd(quarter, year),
dueDate: RETURN_DUE_DATE[quarter](startYear),
lines,
totalPaid: lines.reduce((s, l) => s + l.amountPaid, 0),
totalDeducted: lines.reduce((s, l) => s + l.taxDeducted, 0),
nilDeductionLines: nilDeduction.length,
missingUinLines: nilDeduction.filter((l) => !l.uin).length
};
}
// ───────────────────── 3. Certificate for the deductee ─────────────────────
export interface TdsCertificateQuarterLine {
quarter: Quarter;
label: string;
amountPaid: number;
taxDeducted: number;
/** Tax the bank has actually remitted for this quarter. */
taxDeposited: number;
}
export interface TdsCertificate {
fy: string;
form: string;
section: string;
/** Certificate number, unique per deductee per year. */
certificateNo: string;
deductor: DeductorParticulars;
payeeName: string;
pan: string;
customerId: string;
branchCode: string;
lines: TdsCertificateQuarterLine[];
totalPaid: number;
totalDeducted: number;
/** Interest paid gross against a declaration, disclosed on the certificate. */
paidWithoutDeduction: number;
declarationUin: string | null;
rate: number;
/** A certificate cannot be issued without a valid PAN to issue it against. */
issuable: boolean;
blockingReason: string | null;
}
/**
* The TDS certificate for one deductee.
*
* Quarterly deposits are taken as equal to the quarter's deduction: the branch
* remits what it deducts, and the remittance report is where any gap between the
* two is surfaced. Wiring the certificate to actual challans is the right next
* step once challan records come from the backend rather than the seed.
*/
export function tdsCertificate(row: TdsInterestRow, fy: string): TdsCertificate {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
throw new Error(`No tax configuration for FY ${year}`);
}
const lines: TdsCertificateQuarterLine[] = row.schedule.lines.map((l) => ({
quarter: l.quarter,
label: l.label,
amountPaid: l.interest,
taxDeducted: l.tds,
taxDeposited: Math.max(0, l.tds)
}));
const issuable = isWellFormedPan(row.deductee.pan);
return {
fy: year,
form: cfg.certificateForm,
section: cfg.interestTdsSection,
certificateNo: `${DEDUCTOR.tan}/${uinSafe(year)}/${row.deductee.customerId}`,
deductor: DEDUCTOR,
payeeName: row.deductee.name,
pan: row.deductee.pan,
customerId: row.deductee.customerId,
branchCode: row.branchCode,
lines,
totalPaid: row.tdsApplicableInterest,
totalDeducted: row.schedule.totalTds,
paidWithoutDeduction: row.paidWithoutDeduction,
declarationUin: row.declarationUin,
rate: row.rateApplied,
issuable,
blockingReason: issuable
? null
: `No valid PAN on record. A certificate cannot be issued without one — the deductee would be unable to claim credit.`
};
}
function uinSafe(fy: string): string {
return normaliseFy(fy).replace('-', '');
}
/** Certificates for every deductee who had tax deducted in the year. */
export function tdsCertificates(fy: string): TdsCertificate[] {
return tdsInterestRowsForFy(normaliseFy(fy))
.filter((r) => r.schedule.totalTds > 0 || r.paidWithoutDeduction > 0)
.map((r) => tdsCertificate(r, fy));
}
@@ -21,7 +21,7 @@ import {
MatRow MatRow
} from '@angular/material/table'; } from '@angular/material/table';
import { MatButton, MatIconButton } from '@angular/material/button'; import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { MatProgressBar } from '@angular/material/progress-bar'; import { MatProgressBar } from '@angular/material/progress-bar';
@@ -52,7 +52,7 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog
MatRow, MatRow,
MatButton, MatButton,
MatIconButton, MatIconButton,
MatIconModule, MatIcon,
MatMenu, MatMenu,
MatMenuItem, MatMenuItem,
MatMenuTrigger, MatMenuTrigger,
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260805', 'version': '260803',
'hash': '8c7d8287' 'hash': 'b43ed877'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };