From a982083b148868cfe510e2c1e25d0a1707bdbaeb Mon Sep 17 00:00:00 2001 From: Anshul Rathore Date: Fri, 31 Jul 2026 15:08:35 +0530 Subject: [PATCH 1/6] chore: stop tracking auto-generated .env.ts file --- src/environments/.env.ts | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 src/environments/.env.ts diff --git a/src/environments/.env.ts b/src/environments/.env.ts deleted file mode 100644 index f94dce0fc..000000000 --- a/src/environments/.env.ts +++ /dev/null @@ -1,17 +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/. - */ -// IMPORTANT: THIS FILE IS AUTO GENERATED! DO NOT MANUALLY EDIT OR CHECKIN! -/* tslint:disable */ -export default { - 'mifos_x': { - 'version': '260731', - 'hash': 'ca85bae4' - }, - 'allow_switching_backend_instance': true -}; -/* tslint:enable */ From 26091c68b0e8090eff25dab01a8949c7edda2f36 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 3 Aug 2026 10:51:07 +0530 Subject: [PATCH 2/6] fix(accounting): prioritize specific Fineract error messages when AML marking fails --- .../search-journal-entry/search-journal-entry.component.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts index 78839ffff..60cb80889 100644 --- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts +++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts @@ -327,8 +327,11 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView }, error: (err: any) => { 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 = - err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.'; + err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not mark this entry.'; } }); } From cc5cd4c6abd08011a592ec5abd59847c6f7eff30 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 5 Aug 2026 16:55:21 +0530 Subject: [PATCH 3/6] feat(shares): add Share Withdrawal maker-checker workflow with GL settlement Clerk submits a governed share redemption request (shares, Board Resolution No., Meeting Date, settlement Payment Type); Bank Manager reviews with a live backend-validated checklist (account/GL state, not just submit-time snapshot) before approving, which redeems the shares and settles the linked savings account in one transaction. Adds a Passbook tab to the Share Account view, a reusable search-select control, and a Savings Product "Map Payment Types to Specific Funds" fund-source override (only Loan Products had this before) so settlement can be GL-mapped correctly. Co-Authored-By: Claude Sonnet 5 --- src/app/cashier/cashier-routing.module.ts | 16 + .../cashier/clerk-share-withdrawal.service.ts | 53 ++ .../transaction-entry.component.html | 129 ++++- .../transaction-entry.component.scss | 8 + .../transaction-entry.component.ts | 344 ++++++++++++- src/app/clerk/clerk-shared.scss | 77 +++ ...y-share-withdrawal-requests.component.html | 73 +++ .../my-share-withdrawal-requests.component.ts | 112 +++++ .../share-withdrawal-request.component.html | 393 +++++++++++++++ .../share-withdrawal-request.component.scss | 410 ++++++++++++++++ .../share-withdrawal-request.component.ts | 413 ++++++++++++++++ src/app/core/shell/shell.component.html | 13 + src/app/core/shell/shell.component.ts | 4 +- ...ing-product-accounting-step.component.html | 45 ++ ...aving-product-accounting-step.component.ts | 53 +- ...share-product-accounting-step.component.ts | 11 +- .../search-select/search-select.component.ts | 236 +++++++++ .../passbook-tab/passbook-tab.component.html | 210 ++++++++ .../passbook-tab/passbook-tab.component.scss | 462 ++++++++++++++++++ .../passbook-tab/passbook-tab.component.ts | 182 +++++++ .../shares-account-view.component.html | 10 + .../shares-buttons.config.ts | 8 +- .../transactions-tab.component.html | 19 + .../transactions-tab.component.ts | 24 +- src/app/shares/shares-routing.module.ts | 6 + .../share-withdrawal-requests.component.html | 86 ++++ .../share-withdrawal-requests.component.scss | 60 +++ .../share-withdrawal-requests.component.ts | 128 +++++ .../checker-inbox-and-tasks.component.html | 12 + src/app/tasks/tasks-routing.module.ts | 19 + src/environments/.env.ts | 4 +- 31 files changed, 3565 insertions(+), 55 deletions(-) create mode 100644 src/app/cashier/clerk-share-withdrawal.service.ts create mode 100644 src/app/clerk/clerk-shared.scss create mode 100644 src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html create mode 100644 src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts create mode 100644 src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html create mode 100644 src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss create mode 100644 src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts create mode 100644 src/app/shared/search-select/search-select.component.ts create mode 100644 src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html create mode 100644 src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss create mode 100644 src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index 86568fc2a..fca321b4b 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -62,6 +62,22 @@ const routes: Routes = [ ), 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', loadComponent: () => diff --git a/src/app/cashier/clerk-share-withdrawal.service.ts b/src/app/cashier/clerk-share-withdrawal.service.ts new file mode 100644 index 000000000..fde90ad02 --- /dev/null +++ b/src/app/cashier/clerk-share-withdrawal.service.ts @@ -0,0 +1,53 @@ +/** + * 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 { + return this.http.post('/clerk/share-withdrawal-requests', data); + } + + getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + 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 { + 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 { + return this.http.get(`/clerk/share-withdrawal-requests/${id}`); + } + + validate(id: number): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {}); + } + + approve(id: number): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason }); + } +} diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index 9275e0c3e..0b62d5029 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -146,7 +146,7 @@ [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypes; track pt.id) { + @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -231,8 +231,11 @@ Payment instrument
-
+ @if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { + = β‚Ή{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ β‚Ή{{ + rowSharePrices[row.acNo] | number: '1.2-2' + }}/share + }
- @if (row.typ !== 'Withdrawal Slip') { + @if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') { +
+ + +
+
+ + +
+ } @else if (row.typ !== 'Withdrawal Slip') {
+ @if (selectedRow?.acTyp === 'SHR') { +
+ No. of Shares Held{{ accountDetails.summary?.totalApprovedShares ?? 0 }} +
+
+ Market Price / Share{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }} +
+
+ Resolution No.{{ accountDetails.resolutionNumber || 'β€”' }} +
+
+ Linked Savings A/C No.{{ accountDetails.savingsAccountNumber || 'β€”' }} +
+ @if (accountDetails.linkedLoanAccountNo) { +
+ Linked Loan A/C No.{{ accountDetails.linkedLoanAccountNo }} +
+ } + }
@@ -724,7 +781,13 @@
1. - {{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }} + {{ + selectedRow?.acTyp === 'SHR' + ? 'Share Balance' + : accountDetails?.glCode + ? 'GL Balance' + : 'Current Balance' + }} {{ currencySymbol }}{{ currentBalance | number: '1.2-2' }} @@ -1281,7 +1344,7 @@ [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypes; track pt.id) { + @for (pt of paymentTypesForRow(row); track pt.id) { } @@ -1360,7 +1423,7 @@
+ @if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { + = β‚Ή{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ β‚Ή{{ + rowSharePrices[row.acNo] | number: '1.2-2' + }}/share + }
-
- - -
-
- - -
+ @if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') { +
+ + +
+
+ + +
+ } @else { +
+ + +
+
+ + +
+ }
!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) rowAccountIds: Record = {}; rowAccountNames: Record = {}; + // 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 = {}; + // 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 = {}; + // 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 = {}; // Full account + client data accountDetails: any = null; @@ -1205,6 +1240,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }; this.rows.push(mirror); this.selectedRow = mirror; + if (active.acTyp === 'SHR') { + this.syncShareLinkedAmounts(); + } return; } const row: TxnRow = { @@ -1499,6 +1537,182 @@ 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, + 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. */ onTokNoBlur(row: TxnRow): void { if (this.isClerk || !row.tokNo?.trim()) return; @@ -1535,6 +1749,25 @@ 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 { if (!row.acNo.trim()) return; this.selectedRow = row; @@ -1633,12 +1866,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { if (token !== this.searchToken) return; // stale result β€” newer search started const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []); const f = cfg.matchField; - // Exact match first, then suffix/partial match (backend may return by last-N-digits) + // Exact match first, then suffix/partial match. Some account types (e.g. Share β€” its + // 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 partialMatch = - !exactMatch && allItems.length === 1 - ? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo)) - : null; + const partialMatch = !exactMatch + ? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo))) + : null; const match = exactMatch ?? partialMatch ?? null; if (match?.id) { const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; @@ -1659,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: number; txnBase: string; 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 " SQL statement server-side. */ + siFetchUnsupported?: boolean; maxLimit: number; skipGlobalSearch: boolean; listQueryParam: string; @@ -1693,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: 4, txnBase: '/accounts/share', txnSearchSupported: false, + txnFetchUnsupported: true, + siFetchUnsupported: true, maxLimit: 500, skipGlobalSearch: true, listQueryParam: 'accountNo', @@ -1805,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: number; txnBase: string; txnSearchSupported: boolean; + txnFetchUnsupported?: boolean; + siFetchUnsupported?: boolean; detailParams?: string; }, token: number, @@ -1837,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.rowAccountIds[this.selectedRow.acNo] = acc.id; this.rowAccountNames[this.selectedRow.acNo] = acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? ''; + this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null; } if (this.selectedRow) { - this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? ''; + // Share accounts: ECS field is the share account's own number β€” acc.savingsAccountNumber + // 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); this.computeSameDayNeftRtgs(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; }; @@ -1913,18 +2179,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { documents: this.http.get(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))), notes: this.http.get(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))), linkedAcc: this.http.get(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))), - si: this.http - .get(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`) - .pipe(catchError(() => of({ pageItems: [] }))), - todayTxns: cfg.txnSearchSupported - ? this.http - .get( - `${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20` - ) - .pipe(catchError(() => of({ pageItems: [] }))) + si: cfg.siFetchUnsupported + ? of({ pageItems: [] }) : this.http - .get(`${cfg.txnBase}/${accountId}/transactions?limit=50`) - .pipe(catchError(() => of({ pageItems: [] }))) + .get(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`) + .pipe(catchError(() => of({ pageItems: [] }))), + todayTxns: cfg.txnFetchUnsupported + ? of({ pageItems: [] }) + : cfg.txnSearchSupported + ? this.http + .get( + `${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20` + ) + .pipe(catchError(() => of({ pageItems: [] }))) + : this.http + .get(`${cfg.txnBase}/${accountId}/transactions?limit=50`) + .pipe(catchError(() => of({ pageItems: [] }))) }); if (clientIdHint) { @@ -2089,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // ── Balance getters ──────────────────────────────────────────────────────── 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; } @@ -2502,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { 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 const allowedTypes = new Set([ 'SD', @@ -3004,6 +3296,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const accountId = this.accountDetails.id; 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 deposit β†’ falls through to maker-checker block below if (row.acTyp === 'GL' && !isDeposit) { diff --git a/src/app/clerk/clerk-shared.scss b/src/app/clerk/clerk-shared.scss new file mode 100644 index 000000000..0695f3600 --- /dev/null +++ b/src/app/clerk/clerk-shared.scss @@ -0,0 +1,77 @@ +/** + * 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; + } +} diff --git a/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html new file mode 100644 index 000000000..9d57ea8f4 --- /dev/null +++ b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html @@ -0,0 +1,73 @@ + + +
+
+
+
CLERK Β· SHARE WITHDRAWAL
+

My Share Requests

+

Share withdrawal requests you have submitted, and their approval status.

+
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && requests.length === 0) { + + + + } @else { + @for (r of requests; track r.id) { + + + + + + + + + + } + } + +
Account NoClientSharesResolution No.Meeting DateSubmitted AtStatus
Loading…
No share withdrawal requests found.
+ {{ r.account_no }} + {{ r.client_name }}{{ r.requested_shares }}{{ r.resolution_number }}{{ formatDateTime(r.meeting_date) }}{{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} +
+
+
diff --git a/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts new file mode 100644 index 000000000..3d19c8b57 --- /dev/null +++ b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts @@ -0,0 +1,112 @@ +/** + * 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 = { + 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 = { + 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); + } + } +} diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html new file mode 100644 index 000000000..58810efaf --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html @@ -0,0 +1,393 @@ + + +
+
+
+
{{ isReview ? 'CHECKER Β· APPROVAL' : 'CLERK Β· SHARE WITHDRAWAL' }}
+

{{ pageTitle }}

+

{{ pageSubtitle }}

+
+
+ +
+
+ +
+ +
+ @if (!isReview) { + + } + @if (account) { + + + + + } + @if (loadingAccount) { +
Loading account…
+ } +
+
+ + @if (account) { +
+ +
+ + + + + + + + +
+ + @if (!isReview) { +
+ +
+ } +
+ } + + @if (isReview && reviewRequest?.status === 'PENDING') { +
+ +
+
+ fact_check +
+
+
Run a live backend validation
+
+ Re-checks account status, share balance, linked savings account and GL mapping before you approve. +
+
+ +
+ +
+ + +
+
+ } + + @if (showValidateModal) { +
+
+
+
+ fact_check + Validate Share Withdrawal Request +
+ +
+ +
+

+ Re-checking {{ requestedShares }} share(s) withdrawal from + {{ account?.accountNo }} ({{ account?.clientName }}) against the live account, savings link + and GL configuration β€” not just what was captured at submission time. +

+ + @if (backendChecks && !validating && !validateAllPassed) { +
+ warning +
+ Some checks need review before approving. + Scroll down to see which ones failed and why. +
+
+ } + +
    + @for (step of validateSteps; track step.label; let i = $index) { +
  1. +
    + @if (step.status === 'done') { + check + } @else if (step.status === 'active') { + autorenew + } @else if (step.status === 'failed') { + close + } @else { + {{ i + 1 }} + } +
    +
    +
    {{ step.label }}
    +
    {{ step.detail }}
    +
    +
  2. + } +
+ + @if (validating && validateSteps.length === 0) { +
Connecting to server…
+ } + + @if (backendChecks && !validating && validateAllPassed) { +
+ verified + All checks passed β€” safe to approve. +
+ } +
+ + +
+
+ } + + @if (isReview && reviewRequest?.status === 'APPROVED') { +
+ +
+ @if (reviewRequest?.settlement_amount) { + + + + } @else { +
+ 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. +
+ } +
+
+ } + + @if (isReview && activityLog.length > 0) { +
+ + + + + + + + + + + + @for (entry of activityLog; track entry.timestamp) { + + + + + + + } + +
TimestampActionUserNote
{{ entry.timestamp | date: 'medium' }}{{ entry.action }}{{ entry.user }}{{ entry.note || 'β€”' }}
+
+ } + + @if (showConfirmDialog) { +
+
+

Confirm Share Withdrawal Request

+

Review the details below before submitting for Bank Manager approval.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No / Client{{ account?.accountNo }} β€” {{ account?.clientName }}
Shares to Withdraw + {{ requestedShares }} @ β‚Ή{{ unitPrice | number: '1.2-2' }} = β‚Ή{{ withdrawalValue | number: '1.2-2' }} +
Remaining Shares After Withdrawal{{ remainingShares }}
Requested Date{{ requestedDate }}
Board Resolution Number{{ resolutionNumber }}
Date of Meeting{{ meetingDate }}
Settlement Payment Type{{ settlementPaymentTypeName }}
Linked Savings Account{{ account?.savingsAccountNumber || 'None β€” settlement will not run automatically' }}
+ + + + + @for (check of verificationChecks; track check.label) { + + + + + } + +
{{ check.label }} + {{ check.pass ? 'Pass β€” ' : 'Review β€” ' }}{{ check.detail }} +
+ +
+ + +
+
+
+ } + + @if (showRejectDialog) { +
+
+

Reject Share Withdrawal Request

+ +
+ + +
+
+
+ } +
diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss new file mode 100644 index 000000000..619822696 --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss @@ -0,0 +1,410 @@ +/** + * 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; +} + +/* ---------- 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; + background: rgb(15 25 45 / 55%); + backdrop-filter: blur(3px); + 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; + } +} diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts new file mode 100644 index 000000000..836bfb920 --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts @@ -0,0 +1,413 @@ +/** + * 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(); + @Output() actionCompleted = new EventEmitter(); + + 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']); + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 46f3a0f4d..06e76311b 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -99,6 +99,19 @@ My Disbursements + + + + + + My Share Requests + @if (clerkAccountLoaded) { } + +
+
+

Map Payment Types to Specific Funds

+ +
+
+ @for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) { +
+
+
+ + Payment Type + + @for (paymentType of paymentTypeOptions; track paymentType.id) { + {{ paymentType.name }} + } + + +
+ +
+ + Fund Source + + @for (glAccount of fundSourceAccountOptions; track glAccount.id) { + + {{ '(' + glAccount.glCode + ') ' + glAccount.name }} + + } + + +
+
+ +
+ } +
+
}
diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts index 88e88a51d..986472df6 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts @@ -50,6 +50,11 @@ type OtherGlMappingValue = { glAccountId: number | null; }; +type PaymentFundSourceValue = { + paymentTypeId: number | null; + fundSourceAccountId: number | null; +}; + @Component({ selector: 'mifosx-saving-product-accounting-step', templateUrl: './saving-product-accounting-step.component.html', @@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit { expenseAccountOptions: GLAccount[] = []; incomeAccountOptions: 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[] = []; constructor() { @@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit { this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || []; this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || []; + this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || []; + this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions); this.buildAccountingSectionConfigs(); this.savingProductAccountingForm.patchValue({ accountingRule: accountingRuleId @@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit { this.syncAccountingControls(accountingRuleId); this.patchAccountingValues(mappings); + this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []); this.setOverdraftControl(); this.setDormancyControl(); } @@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit { assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: 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 = {}) { + 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() { this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => { this.syncAccountingControls(accountingRule); diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts index cd20da7bc..14693833d 100644 --- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts +++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts @@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit { liabilityAccountData: GLAccount[] = []; incomeAccountData: 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() { this.createShareProductAccountingForm(); @@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit { this.liabilityAccountData = opts.liabilityAccountOptions || []; this.incomeAccountData = opts.incomeAccountOptions || []; this.equityAccountData = opts.equityAccountOptions || []; + this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData); this.buildAccountingSectionConfigs(); @@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit { rows: [ { key: 'shareReferenceId', - label: 'Share Reference (Cash Vault)', + label: 'Share Reference (Cash Vault or Suspense)', inputLabel: 'Share reference', controlName: 'shareReferenceId', - options: this.assetAccountData, + options: this.referenceAccountData, required: true } ], diff --git a/src/app/shared/search-select/search-select.component.ts b/src/app/shared/search-select/search-select.component.ts new file mode 100644 index 000000000..2a7e27558 --- /dev/null +++ b/src/app/shared/search-select/search-select.component.ts @@ -0,0 +1,236 @@ +/** + * 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 ` + @if (open) { +
    + @for (o of filteredOptions; track o.value) { +
  • + {{ o.label }} +
  • + } @empty { +
  • No matches
  • + } +
+ } +
+ `, + 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; + + 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(); + } +} diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html new file mode 100644 index 000000000..80264c9de --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html @@ -0,0 +1,210 @@ + + +
+ +
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + + + + {{ 'labels.inputs.To Date' | translate }} + + + + + + + + @if (isFiltered) { + + } + + + + +
+
+ + +
+ +
+
+
+ account_balance +
+
+
LincPay Co-operative Bank Ltd.
+
Share Account Passbook
+
+
+
+
SHARE HOLDING STATEMENT
+
As on: {{ maxDate | dateFormat }}
+
+
+ + + + + +
+
+
Approved Shares
+
{{ totalApprovedShares }}
+
+
+
+
Share Value
+
+ {{ currentMarketPrice | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} +
+
+
+
+
Current Value
+
{{ currentValue | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
+
+
+
+
Total Transactions
+
{{ filteredTransactions.length }}
+
+
+ + + @if (filteredTransactions.length > 0) { + + + + + + + + + + + + + + + + @for (txn of filteredTransactions; track txn.id; let i = $index) { + + + + + + + + + + + + } + + + + + + + + +
#DateDescriptionRef No.Shares PurchasedShares RedeemedShare BalanceAmount (₹)Resolution No. / Meeting
{{ i + 1 }}{{ txn.purchasedDate | dateFormat }} + {{ txn.type.value }} ({{ txn.status.value }}) + {{ txn.id }} + @if (!isRedeem(txn)) { + {{ txn.numberOfShares }} + } + + @if (isRedeem(txn)) { + {{ txn.numberOfShares }} + } + {{ txn.runningBalance }} + {{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} + + @if (isRedeem(txn)) { + {{ txn.resolutionNumber || 'β€”' }} + @if (txn.meetingDate) { + ({{ txn.meetingDate | dateFormat }}) + } + } @else { + β€” + } +
Closing Share Balance{{ totalApprovedShares }}
+ } @else { +
+ inbox +

No transactions found for the selected date range.

+
+ } + + + +
+
diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss new file mode 100644 index 000000000..cfe41963e --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss @@ -0,0 +1,462 @@ +/** + * 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; + } +} diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts new file mode 100644 index 000000000..4b882635f --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts @@ -0,0 +1,182 @@ +/** + * 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(` + + + Share Account Passbook - ${this.accountNo} + + + + ${content} + + + `); + 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); + }); + } +} diff --git a/src/app/shares/shares-account-view/shares-account-view.component.html b/src/app/shares/shares-account-view/shares-account-view.component.html index 9761aeeef..4402b9160 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.html +++ b/src/app/shares/shares-account-view/shares-account-view.component.html @@ -145,6 +145,16 @@ > {{ 'labels.inputs.Transactions' | translate }}
+ + Passbook + My Share Requests), + // never a direct one-step action from this view. ]; break; case 'Submitted and pending approval': diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html index f3941f52b..769d76d05 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html @@ -54,6 +54,25 @@ + + Share Balance + {{ transaction.runningBalance }} + + + + Resolution No. / Meeting Date + + @if (transaction.type.value === 'Redeem') { + {{ transaction.resolutionNumber || 'β€”' }} + @if (transaction.meetingDate) { + ({{ transaction.meetingDate | dateFormat }}) + } + } @else { + β€” + } + + + diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts index c28640324..46a7e3db0 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts @@ -56,14 +56,18 @@ export class TransactionsTabComponent implements OnInit { transactionsData: any; /** Data source for transactions table. */ dataSource: MatTableDataSource; - /** Columns to be displayed in transactions table. */ + /** Columns to be displayed in transactions table β€” a share account's passbook: every + * purchase/redeem entry plus the running share balance and, for a withdrawal, the Board + * Resolution Number / Date of Meeting that authorised it. */ displayedColumns: string[] = [ 'transactionDate', 'transactionType', 'totalShares', 'purchasedOrRedeemedPrice', 'chargeAmount', - 'amountRecievedOrReturned' + 'amountRecievedOrReturned', + 'runningBalance', + 'resolutionMeeting' ]; /** @@ -73,9 +77,19 @@ export class TransactionsTabComponent implements OnInit { constructor() { this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { this.shareAccountData = data.sharesAccountData; - this.transactionsData = this.shareAccountData.purchasedShares.filter( - (transaction: any) => transaction.type.value !== 'Charge Payment' - ); + this.transactionsData = this.shareAccountData.purchasedShares + .filter((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(); }); } diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index 4c5193aef..d12979e83 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -26,6 +26,7 @@ import { ShareAccountActionsResolver } from './common-resolvers/share-account-ac import { SharesAccountTemplateResolver } from './common-resolvers/shares-account-template.resolver'; import { SharesAccountAndTemplateResolver } from './common-resolvers/share-account-and-template.resolver'; import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component'; +import { SharesPassbookTabComponent } from './shares-account-view/passbook-tab/passbook-tab.component'; /** Shares Routes */ const routes: Routes = [ @@ -75,6 +76,11 @@ const routes: Routes = [ component: TransactionsTabComponent, data: { title: 'Shares Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false } }, + { + path: 'passbook', + component: SharesPassbookTabComponent, + data: { title: 'Shares Account Passbook', breadcrumb: 'Passbook', routeParamBreadcrumb: false } + }, { path: 'charges', component: ChargesTabComponent, diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html new file mode 100644 index 000000000..347691d98 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html @@ -0,0 +1,86 @@ + + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && requests.length === 0) { + + + + } @else { + @for (r of requests; track r.id) { + + + + + + + + + + + + + } + } + +
Account NoClientSharesResolution No.Meeting DateClerkManagerSubmitted AtStatus
Loading…
No {{ filterStatus | lowercase }} share withdrawal requests.
+ {{ r.account_no }} + {{ r.client_name }}{{ r.requested_shares }}{{ r.resolution_number }}{{ formatDateTime(r.meeting_date) }}{{ r.clerk_name }}{{ r.manager_name || r.manager_username || 'β€”' }}{{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} + + +
+
+ +@if (showReviewModal && reviewRequestId) { +
+
+ +
+
+} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss new file mode 100644 index 000000000..93afc827a --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss @@ -0,0 +1,60 @@ +/** + * 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; + background: rgb(15 25 45 / 55%); + backdrop-filter: blur(3px); + 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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts new file mode 100644 index 000000000..3c21d21ad --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts @@ -0,0 +1,128 @@ +/** + * 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 = { + 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 = { + 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); + } + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index c5a2bfd23..af2a9cd43 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -178,6 +178,18 @@ Withdrawal Requests
+ + + Share Withdrawal Requests + + 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', children: [ diff --git a/src/environments/.env.ts b/src/environments/.env.ts index f94dce0fc..e81c75f5f 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260731', - 'hash': 'ca85bae4' + 'version': '260805', + 'hash': '8c7d8287' }, 'allow_switching_backend_instance': true }; From 569f17ef466625d91944331f0a0744ce40d27902 Mon Sep 17 00:00:00 2001 From: keshav Date: Wed, 5 Aug 2026 17:23:56 +0530 Subject: [PATCH 4/6] kyc exception at client creation time and some tds work --- .../client-general-step.component.ts | 69 + .../client-kyc-step.component.html | 28 + .../client-kyc-step.component.ts | 42 +- src/app/clients/clients-routing.module.ts | 6 + .../clients-view/clients-view.component.html | 9 + .../form121-tab/form121-tab.component.html | 428 +++++++ .../form121-tab/form121-tab.component.scss | 334 +++++ .../form121-tab/form121-tab.component.ts | 500 ++++++++ .../kyc-tab/kyc-tab.component.html | 35 + .../clients-view/kyc-tab/kyc-tab.component.ts | 215 ++-- src/app/clients/clients.service.ts | 22 + .../create-client/create-client.component.ts | 36 +- src/app/clients/kyc-exception.util.spec.ts | 175 +++ src/app/clients/kyc-exception.util.ts | 112 ++ src/app/core/shell/shell.component.html | 21 + .../kyc-management.component.html | 423 ++++++ .../kyc-management.component.scss | 77 ++ .../kyc-management.component.ts | 421 +++++- .../certificates/certificate-pdf.ts | 10 +- .../certificate-preview-dialog.component.html | 1050 +++++++-------- .../certificates/certificates.shared.scss | 69 + .../form-15gh/form-15gh.component.html | 257 +++- .../form-15gh/form-15gh.component.ts | 338 +++-- .../real-form15gh-view.component.html | 329 +++++ .../real-form15gh-view.component.ts | 502 ++++++++ .../form-15gh/tds-15gh.compute.spec.ts | 766 +++++++++++ .../form-15gh/tds-15gh.compute.ts | 1133 +++++++++++++++++ src/app/reports/reports-routing.module.ts | 12 + .../tds-interest-report.component.html | 325 +++++ .../tds-interest-report.component.ts | 250 ++++ .../tds-remittance-report.component.html | 237 ++++ .../tds-remittance-report.component.ts | 203 +++ src/app/tds-reports/tds-reports-data.ts | 396 ++++++ .../tds-reports/tds-reports.compute.spec.ts | 301 +++++ src/app/tds-reports/tds-reports.compute.ts | 395 ++++++ src/app/tds-reports/tds-reports.shared.scss | 546 ++++++++ src/app/tds-reports/tds-statutory.compute.ts | 466 +++++++ 37 files changed, 9800 insertions(+), 738 deletions(-) create mode 100644 src/app/clients/clients-view/form121-tab/form121-tab.component.html create mode 100644 src/app/clients/clients-view/form121-tab/form121-tab.component.scss create mode 100644 src/app/clients/clients-view/form121-tab/form121-tab.component.ts create mode 100644 src/app/clients/kyc-exception.util.spec.ts create mode 100644 src/app/clients/kyc-exception.util.ts create mode 100644 src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html create mode 100644 src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts create mode 100644 src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts create mode 100644 src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts create mode 100644 src/app/tds-reports/tds-interest-report.component.html create mode 100644 src/app/tds-reports/tds-interest-report.component.ts create mode 100644 src/app/tds-reports/tds-remittance-report.component.html create mode 100644 src/app/tds-reports/tds-remittance-report.component.ts create mode 100644 src/app/tds-reports/tds-reports-data.ts create mode 100644 src/app/tds-reports/tds-reports.compute.spec.ts create mode 100644 src/app/tds-reports/tds-reports.compute.ts create mode 100644 src/app/tds-reports/tds-reports.shared.scss create mode 100644 src/app/tds-reports/tds-statutory.compute.ts diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 5cbc3b024..d0fb025da 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -954,6 +954,75 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges Validators.required ] }); + + this.linkMonthlyAnnual('monthlyIncome', 'annualIncome'); + this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover'); + this.linkIncomeToTurnover(); + } + + /** + * Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio β€” typing into either + * one auto-fills the other. Uses { emitEvent: false } on the write side so the two + * subscriptions don't trigger each other back and forth. + */ + /** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */ + private round2(n: number): string { + return (Math.round(n * 100) / 100).toString(); + } + + private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void { + const monthly = this.createClientForm.get(monthlyControlName); + const annual = this.createClientForm.get(annualControlName); + if (!monthly || !annual) { + return; + } + + monthly.valueChanges.subscribe((value) => { + if (this.suppressMonthlyAnnualLink) { + return; + } + const num = parseFloat(value); + annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false }); + }); + + annual.valueChanges.subscribe((value) => { + if (this.suppressMonthlyAnnualLink) { + return; + } + const num = parseFloat(value); + monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false }); + }); + } + + /** + * 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 }); + }); + } } /** diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index b8644c83a..faa14f689 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -723,6 +723,33 @@ PAN verification requires client to be created first. Complete General tab and click Next to save.
} + + +
+
+ + KYC Exception Flag +
+
+ Mark as KYC Exception + + @if (kycForm.get('isException')?.value) { + + Exception Reasons + + @if (!isNonIndividual) { + PAN Card Verification Failed + Aadhaar Verification Failed + } @else { + Enterprise PAN Verification Failed + GSTIN Verification Failed + CIN Verification Failed + } + + + } +
+
@@ -1410,6 +1437,7 @@ +

diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 708188704..4d040cc5c 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types'; +import { deriveKycExceptionFields } from '../../kyc-exception.util'; import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; 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'; @@ -274,6 +275,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { riskCalculationReason = ''; 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 */ panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; panVerifying = false; @@ -468,6 +477,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { allowRiskOverride: [false], allowAmlOverride: [false], remarks: [''], + isException: [false], + exceptionReason: [[]], amlCheckStatus: [ 'CLEAR', Validators.required @@ -610,8 +621,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { dormantFlag: 'ACTIVE', accountStatus: 'ACTIVE', form60Type: 'FORM_60', - form15Type: '15G' + form15Type: '15G', + isException: false, + exceptionReason: [] }); + this.persistedIsException = false; + this.persistedExceptionReason = null; this.kycId = ''; this.kycLoaded = false; this.kycStatus = 0; @@ -942,8 +957,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form60Date: parseDate(data.form60Date), form15Applicable: data.form15Applicable || false, form15Type: data.form15Type || '15G', - form15FY: data.form15FY || '' + form15FY: data.form15FY || '', + isException: data.isException || false, + exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); + this.persistedIsException = data.isException === true; + this.persistedExceptionReason = data.exceptionReason || null; // Sync chip-picker selection with the prefilled document name if (data.kycDocumentName) { this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null; @@ -1843,7 +1862,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form15Applicable: v.form15Applicable || false, form15Type: v.form15Type || 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) + }) }; } @@ -1856,7 +1889,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { v.enterprisePan || v.cinNumber || v.kycDocumentName || - v.form15Applicable + v.form15Applicable || + v.isException ); } diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index 60dc5b155..d1d486fe8 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component'; import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-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 { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard'; @@ -193,6 +194,11 @@ const routes: Routes = [ component: Form60TabComponent, 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', component: DocumentsTabComponent, diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index 2bc58ab72..b14258f63 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -545,6 +545,15 @@ > {{ 'labels.inputs.Form 60' | translate }} + + Form 121 / TDS + + +
+ @if (loading) { +
+ + Reading the customer's deposit ledger… +
+ } @else if (loadError) { +
+ error_outline +
Could not load the customer's accounts. Try again, or check the deposit service.
+
+ } @else { + +
+
+

Form 121 & TDS on deposit interest

+
+ Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest. +
+
+ +
+ @if (availableFys.length) { + + } +
+ Settlement +
+ schedule + {{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }} +
+
+
+
+ + @if (!schedule) { +
+ info_outline +
+ @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. + } +
+
+ } @else { + +
+
+
β‚Ή{{ schedule.totalInterest | number: '1.0-0' }}
+
Interest earned (FY {{ schedule.fy }})
+
+
+
β‚Ή{{ actualTds | number: '1.0-0' }}
+
TDS deducted
+
+
+
β‚Ή{{ schedule.threshold | number: '1.0-0' }}
+
Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})
+
+ @if (thresholdCrossed) { +
+
Crossed
+
TDS applies this year
+
+ } @else { +
+
β‚Ή{{ headroom | number: '1.0-0' }}
+
More interest before TDS starts
+
+ } + @if (tdsSaved > 0) { +
+
β‚Ή{{ tdsSaved | number: '1.0-0' }}
+
Saved by Form 121
+
+ } +
+ + + @if (variance !== 0) { +
+ warning_amber +
+ Ledger and computed tax differ by β‚Ή{{ variance | number: '1.0-0' }}. + 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. +
+
+ } + + @if (declarationNotAppliedToAccounts) { +
+ warning_amber +
+ 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. +
+
+ } + + @if (declarationUselessOnJointAccounts) { +
+ warning_amber +
+ 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. +
+
+ } + + @if (shouldSuggestDeclaration) { +
+ lightbulb_outline +
+ β‚Ή{{ 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. +
+
+ } + + @if (!panWellFormed) { +
+ error_outline +
+ 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. +
+
+ } + + @for (note of schedule.notes; track note) { +
+ info_outline +
{{ note }}
+
+ } + + +
+
+ assignment_turned_in + Declaration +
+
+
+ Status + + @if (declarationOnRecord) { + On record β€” {{ formLabel }} + } @else { + Not filed + } + +
+
+ Form & threshold band + {{ formLabel }} Β· {{ bandLabel }} band Β· {{ age }} yrs in this FY +
+
+ PAN + + {{ pan || 'β€”' }} + @if (pan && !panWellFormed) { + invalid + } + +
+ @if (declarationOnRecord && declarationFy) { +
+ Declared for + FY {{ declarationFy }} +
+ } + @if (declarationOnRecord && !acceptanceDateKnown) { +
+ Acceptance date + + Not captured β€” the declaration is treated as covering the whole year. A mid-year acceptance would change + the figures below. + +
+ } + @if (eligibility) { + @for (reason of eligibility.reasons; track reason) { +
+ Eligibility + {{ reason }} +
+ } + @for (note of eligibility.unassessed; track note) { +
+ Not assessed + {{ note }} +
+ } + } +
+
+ + +
+
+ table_chart + Quarter by quarter +
+
+ + + + + + + + + + + + + @for (line of schedule.lines; track trackLine($index, line)) { + + + + + + + + + } + + + + + + + + + +
QuarterInterestCumulativeLiability to dateTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + threshold crossed + } + @if (line.declarationActive) { + Form 121 active + } + β‚Ή{{ line.interest | number: '1.0-0' }}β‚Ή{{ line.cumInterest | number: '1.0-0' }}β‚Ή{{ line.cumLiability | number: '1.0-0' }} + @if (line.tds !== 0) { + β‚Ή{{ line.tds | number: '1.0-0' }} + } @else { + β€” + } + {{ line.dueDate }}
Total + β‚Ή{{ schedule.totalInterest | number: '1.0-0' }} + + β‚Ή{{ schedule.totalTds | number: '1.0-0' }} +
+
+ @if (schedule.shieldedInterest > 0) { +
+ β‚Ή{{ 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. +
+ } +
+ + +
+
+ account_balance + Deposits counted towards this customer's TDS +
+ @if (liableAccounts.length) { +
+ + + + + + + + + + + + + @for (a of liableAccounts; track trackAccount($index, a)) { + + + + + + + + + } + +
AccountTypeInterest (FY)TDS deductedWithholdingSettles
+
{{ a.accountNo }}
+
{{ a.productName }}
+
{{ a.kind }}β‚Ή{{ a.interest | number: '1.0-0' }}β‚Ή{{ a.tdsDeducted | number: '1.0-0' }} + @if (a.withHoldTax) { + On + @if (a.taxGroup) { +
{{ a.taxGroup }}
+ } + } @else { + Off + } +
{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
+
+
+ Aggregated across every branch β€” a bank on core banking computes the deduction threshold for the bank as a + whole, not branch by branch. +
+ } @else { +
No fixed or recurring deposits where this customer is the deductee.
+ } +
+ + + @if (jointHolderAccounts.length) { +
+
+ group + Deposits where this customer is a joint holder +
+
+ + + + + + + + + + + @for (a of jointHolderAccounts; track trackAccount($index, a)) { + + + + + + + } + +
AccountTypeInterest (FY)Deducted against
+
{{ a.accountNo }}
+
{{ a.productName }}
+
{{ a.kind }}β‚Ή{{ a.interest | number: '1.0-0' }} + First-named holder + @if (a.deducteeClientId) { + (client {{ a.deducteeClientId }}) + } +
+
+
+ Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is + not counted in the figures above and does not use up this customer's threshold. +
+
+ } + + + @if (exemptAccounts.length) { +
+
+ savings + Savings and current accounts +
+
+ + + + + + + + + + + @for (a of exemptAccounts; track trackAccount($index, a)) { + + + + + + + } + +
AccountTypeInterest (FY)TDS
+
{{ a.accountNo }}
+
{{ a.productName }}
+
{{ a.kind }}β‚Ή{{ a.interest | number: '1.0-0' }}Not applicable
+
+
+ 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. +
+
+ } + } + } +
diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.scss b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss new file mode 100644 index 000000000..4f3666cf1 --- /dev/null +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss @@ -0,0 +1,334 @@ +/** + * 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; +} diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts new file mode 100644 index 000000000..e8813db15 --- /dev/null +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts @@ -0,0 +1,500 @@ +/** + * 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(); + + 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(); + + 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 { + const id = `${a.id}`; + const code = a.depositType?.code ?? ''; + let call: Observable; + 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(); + const fys = new Set(); + 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; + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index 820d4877a..cb198b659 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -42,6 +42,19 @@ @if (!isEditMode && kycData) { + @if (kycData.isException && !kycData.exceptionResolved) { +
+ warning +
+ KYC Exception Active +

+ Account is open under a temporary KYC Exception. Reason: {{ kycData.exceptionReason || 'Other' }}. + Please contact the customer and complete verification as soon as possible. +

+
+
+ } +
@@ -740,11 +753,33 @@ + +
+ Mark as KYC Exception + + @if (kycForm.get('isException')?.value) { + + Exception Reasons + + @if (!isNonIndividual) { + PAN Card Verification Failed + Aadhaar Verification Failed + } @else { + Enterprise PAN Verification Failed + GSTIN Verification Failed + CIN Verification Failed + } + + + } +
+ {{ 'labels.inputs.Remarks' | translate }} +
@if (errorMessage) { diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index 2c8e5cef2..38c5a74f2 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; +import { deriveKycExceptionFields } from '../../kyc-exception.util'; import { Dates } from 'app/core/utils/dates'; import { jsPDF } from 'jspdf'; import { MatIcon } from '@angular/material/icon'; @@ -121,7 +122,9 @@ 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}$/)] ], - remarks: [''] + remarks: [''], + isException: [false], + exceptionReason: [[]] }); ngOnInit(): void { @@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy { // the full 12-digit number to re-verify, so don't pre-fill. aadhaarNumber: '', gstinNumber: data.gstinNumber || '', - remarks: data.remarks || '' + remarks: data.remarks || '', + isException: data.isException || false, + exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); } }); @@ -195,6 +200,28 @@ 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 { if (this.kycForm.invalid) { return; @@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy { panNumber: this.kycForm.value.panNumber || null, aadhaarNumberMasked: aadhaarMasked, 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) { @@ -244,6 +274,37 @@ 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 { if (!this.kycId) { this.verificationError.pan = 'KYC record not found. Save KYC details first.'; @@ -254,31 +315,33 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.pan = null; this.verificationDetails.pan = null; - this.clientsService - .verifyPAN(this.clientId, this.kycId) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.verifying.pan = false; - if (response?.changes?.panVerified) { - this.verificationStatus.pan = 'VERIFIED'; - this.verificationDetails.pan = response.changes; - this.loadKycData(); - } else { + this.saveKycBeforeVerify(() => { + this.clientsService + .verifyPAN(this.clientId, this.kycId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: any) => { + this.verifying.pan = false; + if (response?.changes?.panVerified) { + this.verificationStatus.pan = 'VERIFIED'; + this.verificationDetails.pan = response.changes; + this.loadKycData(); + } else { + this.verificationStatus.pan = 'FAILED'; + this.verificationError.pan = 'PAN verification failed.'; + } + }, + error: (err: any) => { + this.verifying.pan = false; this.verificationStatus.pan = 'FAILED'; - this.verificationError.pan = 'PAN verification failed.'; + this.verificationError.pan = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'PAN verification failed. Please try again.'; } - }, - 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.'; - } - }); + }); + }, 'pan'); } /** Show Aadhaar input in summary view for re-verification */ @@ -305,31 +368,33 @@ export class KycTabComponent implements OnInit, OnDestroy { this.aadhaarOtpSent = false; this.aadhaarOtp = ''; - this.clientsService - .sendAadhaarOtp(this.clientId, this.kycId, aadhaar) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.verifying.aadhaar = false; - const refNumber = - response?.changes?.aadhaarRefNumber || - response?.aadhaarRefNumber || - response?.changes?.referenceNumber || - response?.resourceId; - this.aadhaarOtpSent = true; - this.aadhaarOtpReferenceId = refNumber || null; - this.verificationStatus.aadhaar = 'PENDING'; - }, - error: (err: any) => { - this.verifying.aadhaar = false; - this.verificationStatus.aadhaar = 'FAILED'; - this.verificationError.aadhaar = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'Failed to send OTP. Please try again.'; - } - }); + this.saveKycBeforeVerify(() => { + this.clientsService + .sendAadhaarOtp(this.clientId, this.kycId, aadhaar) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: any) => { + this.verifying.aadhaar = false; + const refNumber = + response?.changes?.aadhaarRefNumber || + response?.aadhaarRefNumber || + response?.changes?.referenceNumber || + response?.resourceId; + this.aadhaarOtpSent = true; + this.aadhaarOtpReferenceId = refNumber || null; + this.verificationStatus.aadhaar = 'PENDING'; + }, + error: (err: any) => { + this.verifying.aadhaar = false; + this.verificationStatus.aadhaar = 'FAILED'; + this.verificationError.aadhaar = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'Failed to send OTP. Please try again.'; + } + }); + }, 'aadhaar'); } /** Step 2: Verify OTP entered by user */ @@ -380,31 +445,33 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.gstin = null; this.verificationDetails.gstin = null; - this.clientsService - .verifyGSTIN(this.clientId, this.kycId) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.verifying.gstin = false; - if (response?.changes?.gstinVerified) { - this.verificationStatus.gstin = 'VERIFIED'; - this.verificationDetails.gstin = response.changes; - this.loadKycData(); - } else { + this.saveKycBeforeVerify(() => { + this.clientsService + .verifyGSTIN(this.clientId, this.kycId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: any) => { + this.verifying.gstin = false; + if (response?.changes?.gstinVerified) { + this.verificationStatus.gstin = 'VERIFIED'; + this.verificationDetails.gstin = response.changes; + this.loadKycData(); + } else { + this.verificationStatus.gstin = 'FAILED'; + this.verificationError.gstin = 'GSTIN verification failed.'; + } + }, + error: (err: any) => { + this.verifying.gstin = false; this.verificationStatus.gstin = 'FAILED'; - this.verificationError.gstin = 'GSTIN verification failed.'; + this.verificationError.gstin = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'GSTIN verification failed. Please try again.'; } - }, - 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.'; - } - }); + }); + }, 'gstin'); } formatDate(date: any): string { diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 14a2efc49..ebed2c167 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -958,4 +958,26 @@ export class ClientsService { dispatchWelcomeKit(clientId: string | number, data: any): Observable { return this.http.post(`/clients/${clientId}/welcomekit`, data); } + + getKycExceptions(resolved = false): Observable { + return this.http.get(`/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 { + return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data); + } + + getKycContactLog(clientId: string | number): Observable { + return this.http.get(`/kyc/exceptions/${clientId}/contact-log`); + } } diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 52535f087..6bc6e8cd2 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -430,8 +430,20 @@ export class CreateClientComponent { } this.validationError = ''; // Save KYC - if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { - this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} }); + if (this.clientKycStep?.hasKycData) { + if (!this.clientKycStep.kycId) { + 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 if (this.clientKycStep?.photographFile) { @@ -696,12 +708,20 @@ export class CreateClientComponent { // Persist the KYC document picker selection + uploaded files as a client identifier this.saveKycDocProof(clientId); - // Save KYC only if it wasn't already created during the KYC step - if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { - this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ - next: () => navigateToClient(), - error: () => navigateToClient() - }); + // Save/update KYC to ensure latest exception fields are persisted + if (this.clientKycStep?.hasKycData) { + if (!this.clientKycStep.kycId) { + this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ + next: () => navigateToClient(), + error: () => navigateToClient() + }); + } else { + const { kycType, ...updatePayload } = this.clientKycStep.kycData; + this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({ + next: () => navigateToClient(), + error: () => navigateToClient() + }); + } } else { navigateToClient(); } diff --git a/src/app/clients/kyc-exception.util.spec.ts b/src/app/clients/kyc-exception.util.spec.ts new file mode 100644 index 000000000..1c429ff78 --- /dev/null +++ b/src/app/clients/kyc-exception.util.spec.ts @@ -0,0 +1,175 @@ +/** + * 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); + }); + }); +}); diff --git a/src/app/clients/kyc-exception.util.ts b/src/app/clients/kyc-exception.util.ts new file mode 100644 index 000000000..9d5f4185b --- /dev/null +++ b/src/app/clients/kyc-exception.util.ts @@ -0,0 +1,112 @@ +/** + * 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 + }; +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 06e76311b..793ddf9cc 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -579,6 +579,19 @@ + + + + + + @if (activeSection === 'exceptions') { +
+
+ KYC Exceptions Dashboard +
+ +
+
+ +
+
+
{{ exceptionRows.length }}
+
Pending Exceptions
+
Immediate attention
+
+
+
+ {{ notYetContactedCount }} +
+
Not Yet Contacted
+
Action required
+
+
+
+ {{ panMismatchesCount }} +
+
PAN Mismatches
+
Requires physical review
+
+
+
+ {{ aadhaarFailuresCount }} +
+
Aadhaar Failures
+
Re-KYC OTP needed
+
+
+ +
+ +
+
+
+
Exceptions Register
+
+ +
+ + +
+
+ + + @if (activeExceptionSubTab === 'pending') { + @if (pendingExceptions.length === 0) { +
+
πŸŽ‰
+
All exceptions resolved!
+
There are no active client KYC exceptions on record.
+
+ } @else { + + + + + + + + + + + + + + @for (c of pendingExceptions; track c.id) { + + + + + + + + + + } + +
Client / AccountBranchException Reason & DateRiskContact StatusContact LogsActions
+
{{ c.name }}
+
{{ c.account }}
+
{{ c.contact.branch }} +
{{ c.exceptionReason }}
+
Opened: {{ c.exceptionDate }}
+
+ {{ c.riskLabel }} + + @if (c.contactHistory && c.contactHistory.length > 0) { + Contacted +
Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}
+ } @else { + Not Contacted + } +
+ @if (c.contactHistory && c.contactHistory.length > 0) { +
+ @for (log of c.contactHistory; track $index) { +
+ {{ log.date }} ({{ log.action }}): {{ log.notes }} β€” {{ log.officer }} + @if (log.platformSent) { + @if (log.delivered) { + Sent {{ log.destinationMasked }} + } @else { + Not sent + } + } +
+ } +
+ } @else { + No logs recorded + } +
+
+ + +
+
+ } + } + + + @if (activeExceptionSubTab === 'resolved') { + @if (resolvedExceptions.length === 0) { +
+
πŸ“‹
+
No resolved exceptions
+
Resolved KYC exceptions will appear here once verification is completed.
+
+ } @else { + + + + + + + + + + + + + @for (c of resolvedExceptions; track c.id) { + + + + + + + + + } + +
Client / AccountBranchException Reason & ResolutionRiskContact LogsStatus
+
{{ c.name }}
+
{{ c.account }}
+
{{ c.contact.branch }} +
{{ c.exceptionReason }}
+
Resolved On: {{ c.exceptionResolvedOn }}
+
+ {{ c.riskLabel }} + + @if (c.contactHistory && c.contactHistory.length > 0) { +
+ @for (log of c.contactHistory; track $index) { +
+ {{ log.date }} ({{ log.action }}): {{ log.notes }} β€” {{ log.officer }} + @if (log.platformSent) { + @if (log.delivered) { + Sent {{ log.destinationMasked }} + } @else { + Not sent + } + } +
+ } +
+ } @else { + No logs recorded + } +
+ + Exception Resolved + +
+ } + } +
+
+ }
@@ -1165,6 +1392,202 @@

} + + @if (contactDialogOpen && current) { +
+
+
+
{{ current.av }}
+
+
Log Customer Contact
+
{{ current.name }} Β· {{ current.account }}
+
+
βœ•
+
+ +
+
+
Customer Contact Info
+
{{ current.name }}
+
+ {{ current.contact.mobile }} +
+
+ {{ current.contact.email }} +
+
+ +
+ + + @if (selectedChannelSends) { +
+ + A KYC notice will be sent to the customer when you save. +
+ } @else { +
+ Records an action you already took. Nothing is sent to the customer. +
+ } + @if (selectedChannelUnavailable) { +
+ This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on file β€” + choose a different channel. +
+ } +
+ +
+ + +
+ +
+ + +
+ + @if (contactError) { + + } +
+ + +
+
+ } + + + @if (resolveDialogOpen && current) { +
+
+
+
{{ current.av }}
+
+
Resolve KYC Exception
+
{{ current.name }} Β· {{ current.account }}
+
+
βœ•
+
+ +
+
+ Pending Reason: {{ current.exceptionReason }} + @if (current.exceptionRemarks) { +
"{{ current.exceptionRemarks }}"
+ } +
+ + @if (showPanResolution) { +
+
+ + +
+ @if (resolvePanActive) { +
+ + + @if (resolvePanNumber && resolvePanNumber.length !== 10) { +
PAN must be exactly 10 characters.
+ } +
+ } +
+ } + + @if (showAadhaarResolution) { +
+
+ + +
+ @if (resolveAadhaarActive) { +
+ + + @if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) { +
Aadhaar must be exactly 12 digits.
+ } +
+ } +
+ } + + @if (showGeneralResolution) { +
+
+ + +
+ @if (resolveGeneralActive) { +
+
+ + +
+
+ + +
+
+ } +
+ } + +
+ + +
+
+ + +
+
+ } +
{{ toastMsg }}
+ diff --git a/src/app/kyc-management/kyc-management.component.scss b/src/app/kyc-management/kyc-management.component.scss index bef8750f6..7bff6b323 100644 --- a/src/app/kyc-management/kyc-management.component.scss +++ b/src/app/kyc-management/kyc-management.component.scss @@ -1588,6 +1588,58 @@ 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 { padding: 14px 22px; border-top: 1px solid var(--km-g200); @@ -1648,3 +1700,28 @@ color: #0f172a; 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; + } +} diff --git a/src/app/kyc-management/kyc-management.component.ts b/src/app/kyc-management/kyc-management.component.ts index b3aa34bae..76d33091a 100644 --- a/src/app/kyc-management/kyc-management.component.ts +++ b/src/app/kyc-management/kyc-management.component.ts @@ -6,10 +6,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { ClientsService } from '../clients/clients.service'; interface KmNotice { no: string; @@ -31,6 +32,24 @@ interface KmEvidence { 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 { id: string; av: string; @@ -66,6 +85,17 @@ interface KmClient { pendingActions: string[]; actionTaken: string[]; 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({ @@ -79,7 +109,8 @@ interface KmClient { ] }) export class KycManagementComponent implements OnInit { - activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts'; + private clientsService = inject(ClientsService); + activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts'; modalOpen = false; modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview'; @@ -529,15 +560,399 @@ export class KycManagementComponent implements OnInit { }; 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 { this.alertRows = Object.values(this.clients); + this.loadExceptions(); } - showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void { + loadExceptions(): 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; } + 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 { const c = this.clients[id]; if (!c) return; diff --git a/src/app/letter-mgmt/certificates/certificate-pdf.ts b/src/app/letter-mgmt/certificates/certificate-pdf.ts index 91aebd362..88a5cecc4 100644 --- a/src/app/letter-mgmt/certificates/certificate-pdf.ts +++ b/src/app/letter-mgmt/certificates/certificate-pdf.ts @@ -1069,12 +1069,10 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF { y = paintPara( c, y, - `Declaration under Section ${ - kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' - } of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes β€” ${ - kind === '15H' - ? 'applicable to resident individuals aged 60 years or above' - : 'applicable to resident individuals (other than companies / firms) below 60 years' + `Declaration under Section ${kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' + } of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes β€” ${kind === '15H' + ? 'applicable to resident individuals aged 60 years or above' + : 'applicable to resident individuals (other than companies / firms) below 60 years' }.` ); diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html index a0b6381fd..45919091c 100644 --- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html +++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html @@ -49,9 +49,9 @@
Certificate Reference
@if (isInterest && interest) { - INT-{{ interest.record.accountNo }}-{{ interest.fy.fy | slice: 3 }} + INT-{{ interest.record.accountNo }}-{{ interest.fy.fy | slice: 3 }} } @else if (isShare && share) { - {{ share.certificateNo }} + {{ share.certificateNo }} }
@@ -62,38 +62,38 @@
@if (isInterest) { - Financial Year + Financial Year } @else if (isShare) { - Folio No. + Folio No. } @else if (isBalance) { - As-on Date + As-on Date } @else if (isForm60) { - Trigger + Trigger } @else if (isForm15gh) { - Form + Form } @else if (isHypothecation) { - Status + Status } @else if (isNoc) { - Status + Status }
@if (isInterest && interest) { - {{ interest.fy.fy }} + {{ interest.fy.fy }} } @else if (isShare && share) { - {{ share.folioNo }} + {{ share.folioNo }} } @else if (isBalance && balance) { - {{ balance.asOnDate | date: 'd MMM y' }} + {{ balance.asOnDate | date: 'd MMM y' }} } @else if (isForm60 && form60) { - No PAN β€” Form 97 + No PAN β€” Form 97 } @else if (isForm15gh && form15gh) { - Form {{ form15gh.kind }} + Form {{ form15gh.kind }} } @else if (isHypothecation && hypothecation) { - {{ hypothecation.status }} + {{ hypothecation.status }} } @else if (isNoc && noc) { - - {{ noc.status === 'READY_TO_ISSUE' ? 'Ready to Issue' : noc.status }} - + + {{ noc.status === 'READY_TO_ISSUE' ? 'Ready to Issue' : noc.status }} + }
@@ -101,557 +101,557 @@ @if (isInterest && interest) { -
-
To,
-
-
- {{ interest.record.customerName }} -
-
- Customer ID: {{ interest.record.customerId }} Β· PAN: - {{ interest.record.customerPan }} -
-
{{ interest.record.customerAddress }}
+
+
To,
+
+
+ {{ interest.record.customerName }}
+
+ Customer ID: {{ interest.record.customerId }} Β· PAN: + {{ interest.record.customerPan }} +
+
{{ interest.record.customerAddress }}
+
-

- This is to certify that {{ interest.record.customerName }} (Customer ID - {{ interest.record.customerId }}) is availing the following loan facility from our bank, and the interest paid by the above customer during the - financial year {{ interest.fy.fy }} is as detailed below. -

+

+ This is to certify that {{ interest.record.customerName }} (Customer ID + {{ interest.record.customerId }}) is availing the following loan facility from our bank, and the + interest paid by the above customer during the + financial year {{ interest.fy.fy }} is as detailed below. +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Loan Account No. - {{ interest.record.accountNo }} - Branch{{ interest.record.branch }}
Loan Type{{ interest.record.loanType }}Disbursed On{{ interest.record.disbursementDate | date: 'd MMM y' }}
Sanctioned Amountβ‚Ή{{ interest.record.sanctionedAmount | number: '1.2-2' }}Interest Rate{{ interest.record.interestRate }}% p.a.
Opening Balance ({{ interest.fy.fy | slice: 0 : 8 }})β‚Ή{{ interest.fy.openingBalance | number: '1.2-2' }}Closing Balanceβ‚Ή{{ interest.fy.closingBalance | number: '1.2-2' }}
Principal Repaidβ‚Ή{{ interest.fy.principalPaid | number: '1.2-2' }}Interest Paid - β‚Ή{{ interest.fy.interestPaid | number: '1.2-2' }} -
TOTAL INTEREST PAID DURING {{ interest.fy.fy }} - β‚Ή{{ interest.fy.interestPaid | number: '1.2-2' }} -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Loan Account No. + {{ interest.record.accountNo }} + Branch{{ interest.record.branch }}
Loan Type{{ interest.record.loanType }}Disbursed On{{ interest.record.disbursementDate | date: 'd MMM y' }}
Sanctioned Amountβ‚Ή{{ interest.record.sanctionedAmount | number: '1.2-2' }}Interest Rate{{ interest.record.interestRate }}% p.a.
Opening Balance ({{ interest.fy.fy | slice: 0 : 8 }})β‚Ή{{ interest.fy.openingBalance | number: '1.2-2' }}Closing Balanceβ‚Ή{{ interest.fy.closingBalance | number: '1.2-2' }}
Principal Repaidβ‚Ή{{ interest.fy.principalPaid | number: '1.2-2' }}Interest Paid + β‚Ή{{ interest.fy.interestPaid | number: '1.2-2' }} +
TOTAL INTEREST PAID DURING {{ interest.fy.fy }} + β‚Ή{{ interest.fy.interestPaid | number: '1.2-2' }} +
-

- This certificate is issued at the request of the borrower for income-tax / financial record purposes and - reflects the interest credited to the captioned loan account during the said financial year as per our books. -

+

+ This certificate is issued at the request of the borrower for income-tax / financial record purposes and + reflects the interest credited to the captioned loan account during the said financial year as per our books. +

} @if (isShare && share) { -
-
Issued To,
-
-
- {{ share.memberName }} -
-
- Member ID: {{ share.memberId }} Β· PAN: - {{ share.memberPan }} -
-
{{ share.memberAddress }}
+
+
Issued To,
+
+
+ {{ share.memberName }}
+
+ Member ID: {{ share.memberId }} Β· PAN: + {{ share.memberPan }} +
+
{{ share.memberAddress }}
+
-

- This is to certify that {{ share.memberName }} is the registered holder of - {{ share.shareCount | number }} ({{ classLabelText() }}) shares of - β‚Ή{{ share.faceValue | number: '1.0-0' }} each, fully paid up, in {{ bankName }}, subject to the bye-laws of the bank. -

+

+ This is to certify that {{ share.memberName }} is the registered holder of + {{ share.shareCount | number }} ({{ classLabelText() }}) shares of + β‚Ή{{ share.faceValue | number: '1.0-0' }} each, fully paid up, in {{ bankName }}, + subject to the bye-laws of the bank. +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Certificate No. - {{ share.certificateNo }} - Folio No.{{ share.folioNo }}
Branch{{ share.branch }}Member Since{{ share.joiningDate | date: 'd MMM y' }}
Share Class{{ classLabelText() }}Date of Issue{{ share.issueDate | date: 'd MMM y' }}
Share Numbers{{ share.shareNoFrom | number }} – {{ share.shareNoTo | number }}Dividend Paid (Cumulative)β‚Ή{{ share.dividendYtd | number: '1.0-0' }}
TOTAL FACE VALUE - β‚Ή{{ share.shareCount * share.faceValue | number: '1.2-2' }} -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Certificate No. + {{ share.certificateNo }} + Folio No.{{ share.folioNo }}
Branch{{ share.branch }}Member Since{{ share.joiningDate | date: 'd MMM y' }}
Share Class{{ classLabelText() }}Date of Issue{{ share.issueDate | date: 'd MMM y' }}
Share Numbers{{ share.shareNoFrom | number }} – {{ share.shareNoTo | number }}Dividend Paid (Cumulative)β‚Ή{{ share.dividendYtd | number: '1.0-0' }}
TOTAL FACE VALUE + β‚Ή{{ share.shareCount * share.faceValue | number: '1.2-2' }} +
-

- Given under the common seal of the bank this {{ issuedOn | date: 'd' }} day of {{ issuedOn | date: 'MMMM y' }}. -

+

+ Given under the common seal of the bank this {{ issuedOn | date: 'd' }} day of {{ issuedOn | date: 'MMMM y' }}. +

} @if (isBalance && balance) { -
-
To Whom It May Concern,
-
-
- {{ balance.record.customerName }} -
-
- Customer ID: {{ balance.record.customerId }} Β· PAN: - {{ balance.record.customerPan }} -
-
{{ balance.record.customerAddress }}
- @if (balance.record.jointHolders) { -
Jointly with: {{ balance.record.jointHolders }}
- } +
+
To Whom It May Concern,
+
+
+ {{ balance.record.customerName }}
+
+ Customer ID: {{ balance.record.customerId }} Β· PAN: + {{ balance.record.customerPan }} +
+
{{ balance.record.customerAddress }}
+ @if (balance.record.jointHolders) { +
Jointly with: {{ balance.record.jointHolders }}
+ }
+
-

- This is to certify that {{ balance.record.customerName }} (Customer ID - {{ balance.record.customerId }}) maintains the following account with our bank, and the balance available in the said account as on - {{ balance.asOnDate | date: 'd MMMM y' }} is as detailed below. -

+

+ This is to certify that {{ balance.record.customerName }} (Customer ID + {{ balance.record.customerId }}) maintains the following account with our bank, and the balance + available in the said account as on + {{ balance.asOnDate | date: 'd MMMM y' }} is as detailed below. +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Account No. - {{ balance.record.accountNo }} - Branch{{ balance.record.branch }}
Account Type{{ accountTypeLabelText() }}Product{{ balance.record.productName }}
Account Opened{{ balance.record.openedOn | date: 'd MMM y' }}Interest Rate - @if (balance.record.interestRate) { - {{ balance.record.interestRate }}% p.a. - } @else { - β€” - } -
Available Balanceβ‚Ή{{ balance.record.availableBalance | number: '1.2-2' }}Accrued Interestβ‚Ή{{ balance.record.accruedInterest | number: '1.2-2' }}
TOTAL LEDGER BALANCE AS ON {{ balance.asOnDate | date: 'd MMM y' }} - β‚Ή{{ balance.record.ledgerBalance | number: '1.2-2' }} -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No. + {{ balance.record.accountNo }} + Branch{{ balance.record.branch }}
Account Type{{ accountTypeLabelText() }}Product{{ balance.record.productName }}
Account Opened{{ balance.record.openedOn | date: 'd MMM y' }}Interest Rate + @if (balance.record.interestRate) { + {{ balance.record.interestRate }}% p.a. + } @else { + β€” + } +
Available Balanceβ‚Ή{{ balance.record.availableBalance | number: '1.2-2' }}Accrued Interestβ‚Ή{{ balance.record.accruedInterest | number: '1.2-2' }}
TOTAL LEDGER BALANCE AS ON {{ balance.asOnDate | date: 'd MMM y' }} + β‚Ή{{ balance.record.ledgerBalance | number: '1.2-2' }} +
-

- This certificate is issued at the request of the customer for record purposes and reflects the balance available - in the said account as per our books as on the date mentioned above. -

+

+ This certificate is issued at the request of the customer for record purposes and reflects the balance available + in the said account as per our books as on the date mentioned above. +

} @if (isForm60 && form60) { -

- Declaration under Section 114B of the Income-tax Rules, 1962, to be made by a person who does - not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B. -

+

+ Declaration under Section 114B of the Income-tax Rules, 1962, to be made by a person who does + not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B. +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1. Full Name (in Block Letters) - {{ form60.customerName }} -
2. Father's / Spouse's Name{{ form60.fatherName }}
3. Date of Birth{{ form60.dob | date: 'd MMM y' }}4. Customer ID{{ form60.customerId }}
5. Address{{ form60.customerAddress }}
6. ID Proof Submitted{{ idProofLabelText() }}7. ID Document No.{{ form60.idProofNumber }}
8. Nature of Transaction{{ form60.transactionType }}
9. Transaction Amountβ‚Ή{{ form60.transactionAmount | number: '1.2-2' }}10. Transaction Date{{ form60.transactionDate | date: 'd MMM y' }}
11. Estimated Total Income (Current FY)β‚Ή{{ form60.estimatedIncome | number: '1.0-0' }}12. Agricultural Incomeβ‚Ή{{ form60.agriIncome | number: '1.0-0' }}
PAN STATUS - NOT ALLOTTED β€” Form 97 declared -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1. Full Name (in Block Letters) + {{ form60.customerName }} +
2. Father's / Spouse's Name{{ form60.fatherName }}
3. Date of Birth{{ form60.dob | date: 'd MMM y' }}4. Customer ID{{ form60.customerId }}
5. Address{{ form60.customerAddress }}
6. ID Proof Submitted{{ idProofLabelText() }}7. ID Document No.{{ form60.idProofNumber }}
8. Nature of Transaction{{ form60.transactionType }}
9. Transaction Amountβ‚Ή{{ form60.transactionAmount | number: '1.2-2' }}10. Transaction Date{{ form60.transactionDate | date: 'd MMM y' }}
11. Estimated Total Income (Current FY)β‚Ή{{ form60.estimatedIncome | number: '1.0-0' }}12. Agricultural Incomeβ‚Ή{{ form60.agriIncome | number: '1.0-0' }}
PAN STATUS + NOT ALLOTTED β€” Form 97 declared +
-

- I, {{ form60.customerName }}, do hereby declare that the particulars given above are true and correct to the best of my knowledge and - belief. I further declare that I have applied / not applied for a Permanent Account Number with the Income-tax - department, and that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961. -

+

+ I, {{ form60.customerName }}, do hereby declare that the particulars given above are true and + correct to the best of my knowledge and + belief. I further declare that I have applied / not applied for a Permanent Account Number with the Income-tax + department, and that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961. +

} @if (isForm15gh && form15gh) { -

- Declaration under Section {{ form15gh.kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' }} - of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes β€” - {{ - form15gh.kind === '15H' - ? 'applicable to resident individuals aged 60 years or above' - : 'applicable to resident individuals (other than companies / firms) below 60 years' - }}. -

+

+ Declaration under Section {{ form15gh.kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' }} + of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes β€” + {{ + form15gh.kind === '15H' + ? 'applicable to resident individuals aged 60 years or above' + : 'applicable to resident individuals (other than companies / firms) below 60 years' + }}. +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1. Name of Assessee - {{ form15gh.record.customerName }} -
2. PAN of Assessee{{ form15gh.record.customerPan }}3. StatusIndividual
4. Previous Year{{ form15gh.record.fy }}5. Age (years){{ form15gh.age }}
6. Address{{ form15gh.record.customerAddress }}
7. Branch{{ form15gh.record.branch }}8. Customer ID{{ form15gh.record.customerId }}
9. Deposit Principalβ‚Ή{{ form15gh.record.depositPrincipal | number: '1.0-0' }}10. Estimated Interest ({{ form15gh.record.fy }}) - β‚Ή{{ form15gh.record.estimatedInterest | number: '1.0-0' }} -
11. Estimated Total Incomeβ‚Ή{{ form15gh.record.estimatedIncome | number: '1.0-0' }}12. Previously Assessed?{{ form15gh.record.assessed ? 'Yes' : 'No' }}
TAX PAYABLE ON ESTIMATED INCOMENIL
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1. Name of Assessee + {{ form15gh.record.customerName }} +
2. PAN of Assessee{{ form15gh.record.customerPan }}3. StatusIndividual
4. Previous Year{{ form15gh.record.fy }}5. Age (years){{ form15gh.age }}
6. Address{{ form15gh.record.customerAddress }}
7. Branch{{ form15gh.record.branch }}8. Customer ID{{ form15gh.record.customerId }}
9. Deposit Principalβ‚Ή{{ form15gh.record.depositPrincipal | number: '1.0-0' }}10. Estimated Interest ({{ form15gh.record.fy }}) + β‚Ή{{ form15gh.record.estimatedInterest | number: '1.0-0' }} +
11. Estimated Total Incomeβ‚Ή{{ form15gh.record.estimatedIncome | number: '1.0-0' }}12. Previously Assessed?{{ form15gh.record.assessed ? 'Yes' : 'No' }}
TAX PAYABLE ON ESTIMATED INCOMENIL
-

- I, {{ form15gh.record.customerName }}, do hereby declare that the particulars given above are true and correct, and that the tax on my estimated - total income for the previous year {{ form15gh.record.fy }} will be NIL. I therefore request that no tax be - deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous - year. -

+

+ I, {{ form15gh.record.customerName }}, do hereby declare that the particulars given above are + true and correct, and that the tax on my estimated + total income for the previous year {{ form15gh.record.fy }} will be NIL. I therefore request that no tax be + deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous + year. +

} @if (isHypothecation && hypothecation) { -
-
Issued To,
-
-
- {{ hypothecation.customerName }} -
-
- Customer ID: {{ hypothecation.customerId }} Β· PAN: - {{ hypothecation.customerPan }} -
-
{{ hypothecation.customerAddress }}
+
+
Issued To,
+
+
+ {{ hypothecation.customerName }}
+
+ Customer ID: {{ hypothecation.customerId }} Β· PAN: + {{ hypothecation.customerPan }} +
+
{{ hypothecation.customerAddress }}
+
-

- This is to certify that the asset described below stands hypothecated to - {{ bankName }} as security for the captioned loan facility extended to - {{ hypothecation.customerName }}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged. -

+

+ This is to certify that the asset described below stands hypothecated to + {{ bankName }} as security for the captioned loan facility extended to + {{ hypothecation.customerName }}, and the bank holds the first and exclusive charge over the said + asset until the loan is fully discharged. +

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @if (hypothecation.insuredValue > 0) { - - - - - - - } - - - - - -
Certificate No. - {{ hypothecation.certificateNo }} - Loan Account No.{{ hypothecation.accountNo }}
Loan Type{{ hypothecation.loanType }}Branch{{ hypothecation.branch }}
Sanctioned Amountβ‚Ή{{ hypothecation.sanctionedAmount | number: '1.0-0' }}Disbursed On{{ hypothecation.disbursementDate | date: 'd MMM y' }}
Asset Type{{ assetLabelText() }}Identifier{{ hypothecation.assetIdentifier }}
Asset Description{{ hypothecation.assetDescription }}
Insured Valueβ‚Ή{{ hypothecation.insuredValue | number: '1.0-0' }}Insurance Valid Till{{ hypothecation.insuranceValidTill | date: 'd MMM y' }}
OUTSTANDING PRINCIPAL - β‚Ή{{ hypothecation.outstandingPrincipal | number: '1.2-2' }} -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if (hypothecation.insuredValue > 0) { + + + + + + + } + + + + + +
Certificate No. + {{ hypothecation.certificateNo }} + Loan Account No.{{ hypothecation.accountNo }}
Loan Type{{ hypothecation.loanType }}Branch{{ hypothecation.branch }}
Sanctioned Amountβ‚Ή{{ hypothecation.sanctionedAmount | number: '1.0-0' }}Disbursed On{{ hypothecation.disbursementDate | date: 'd MMM y' }}
Asset Type{{ assetLabelText() }}Identifier{{ hypothecation.assetIdentifier }}
Asset Description{{ hypothecation.assetDescription }}
Insured Valueβ‚Ή{{ hypothecation.insuredValue | number: '1.0-0' }}Insurance Valid Till{{ hypothecation.insuranceValidTill | date: 'd MMM y' }}
OUTSTANDING PRINCIPAL + β‚Ή{{ hypothecation.outstandingPrincipal | number: '1.2-2' }} +
-

- The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC) - after the entire outstanding under the captioned facility is paid in full, together with all interest, charges, - and incidental costs. -

+

+ The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC) + after the entire outstanding under the captioned facility is paid in full, together with all interest, charges, + and incidental costs. +

} @if (isNoc && noc) { -
-
Issued To,
-
-
- {{ noc.customerName }} -
-
- Customer ID: {{ noc.customerId }} Β· PAN: - {{ noc.customerPan }} -
-
{{ noc.customerAddress }}
+
+
Issued To,
+
+
+ {{ noc.customerName }}
+
+ Customer ID: {{ noc.customerId }} Β· PAN: + {{ noc.customerPan }} +
+
{{ noc.customerAddress }}
+
-

- This is to certify that the captioned loan account in the name of - {{ noc.customerName }} - has been - @if (noc.status === 'ISSUED') { - duly closed and the bank - } @else { - closed and the bank - } - has NO OBJECTION to the release of the lien hereinafter recorded over the asset described - below. All dues under the said facility, including principal, interest, and incidental charges, have been - received by the bank as on the closure date. -

- - - - - - - - - - - - - - - - - - - - - - @if (noc.closureDate) { - - - - - - - } - @if (noc.assetType !== 'NONE') { - - - - - - - - - - - } - @if (noc.forwardTo) { - - - - - } - - - - - -
NOC No. - {{ noc.nocNo }} - Loan Account No.{{ noc.accountNo }}
Loan Type{{ nocLoanTypeLabel() }}Branch{{ noc.branch }}
Sanctioned Amountβ‚Ή{{ noc.sanctionedAmount | number: '1.0-0' }}Disbursed On{{ noc.disbursementDate | date: 'd MMM y' }}
Account Closed On - {{ noc.closureDate | date: 'd MMM y' }} - Total Paidβ‚Ή{{ noc.totalPaid | number: '1.2-2' }}
Asset Type{{ nocAssetLabel() }}Identifier{{ noc.assetIdentifier }}
Asset Description{{ noc.assetDescription }}
Forward To{{ noc.forwardTo }}
- @if (noc.assetType === 'NONE') { - ACCOUNT CLOSED β€” NO DUES OUTSTANDING - } @else { - LIEN RELEASED β€” NO OBJECTION TO TRANSFER - } - NIL
- -

- @if (noc.assetType === 'VEHICLE') { - The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from - the registration certificate of the captioned vehicle. - } @else if (noc.assetType === 'PROPERTY') { - The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from - the property records of the captioned property. - } @else if (noc.assetType === 'GOLD') { - The pledged gold ornaments described above may be released to the customer / authorised representative after - due verification at the branch counter. - } @else if (noc.assetType !== 'NONE') { - The bank's lien over the captioned asset stands released with effect from the closure date, and the customer - is free to deal with the said asset. - } @else { - This certificate is issued at the request of the customer for record purposes and confirms that no dues are - outstanding under the captioned facility. - } -

- - @if (noc.status === 'ISSUED' && noc.issuedOn) { -

- Issued on: {{ noc.issuedOn | date: 'd MMM y' }} - @if (noc.issuedBy) { - Β· By: {{ noc.issuedBy }} - } -

+

+ This is to certify that the captioned loan account in the name of + {{ noc.customerName }} + has been + @if (noc.status === 'ISSUED') { + duly closed and the bank + } @else { + closed and the bank } + has NO OBJECTION to the release of the lien hereinafter recorded over the asset described + below. All dues under the said facility, including principal, interest, and incidental charges, have been + received by the bank as on the closure date. +

+ + + + + + + + + + + + + + + + + + + + + + @if (noc.closureDate) { + + + + + + + } + @if (noc.assetType !== 'NONE') { + + + + + + + + + + + } + @if (noc.forwardTo) { + + + + + } + + + + + +
NOC No. + {{ noc.nocNo }} + Loan Account No.{{ noc.accountNo }}
Loan Type{{ nocLoanTypeLabel() }}Branch{{ noc.branch }}
Sanctioned Amountβ‚Ή{{ noc.sanctionedAmount | number: '1.0-0' }}Disbursed On{{ noc.disbursementDate | date: 'd MMM y' }}
Account Closed On + {{ noc.closureDate | date: 'd MMM y' }} + Total Paidβ‚Ή{{ noc.totalPaid | number: '1.2-2' }}
Asset Type{{ nocAssetLabel() }}Identifier{{ noc.assetIdentifier }}
Asset Description{{ noc.assetDescription }}
Forward To{{ noc.forwardTo }}
+ @if (noc.assetType === 'NONE') { + ACCOUNT CLOSED β€” NO DUES OUTSTANDING + } @else { + LIEN RELEASED β€” NO OBJECTION TO TRANSFER + } + NIL
+ +

+ @if (noc.assetType === 'VEHICLE') { + The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from + the registration certificate of the captioned vehicle. + } @else if (noc.assetType === 'PROPERTY') { + The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from + the property records of the captioned property. + } @else if (noc.assetType === 'GOLD') { + The pledged gold ornaments described above may be released to the customer / authorised representative after + due verification at the branch counter. + } @else if (noc.assetType !== 'NONE') { + The bank's lien over the captioned asset stands released with effect from the closure date, and the customer + is free to deal with the said asset. + } @else { + This certificate is issued at the request of the customer for record purposes and confirms that no dues are + outstanding under the captioned facility. + } +

+ + @if (noc.status === 'ISSUED' && noc.issuedOn) { +

+ Issued on: {{ noc.issuedOn | date: 'd MMM y' }} + @if (noc.issuedBy) { + Β· By: {{ noc.issuedBy }} + } +

+ } } @@ -660,25 +660,25 @@
Authorised Signatory
@if (isInterest && interest) { -
{{ interest.record.branch }}
+
{{ interest.record.branch }}
} @if (isShare && share) { -
{{ share.branch }}
+
{{ share.branch }}
} @if (isBalance && balance) { -
{{ balance.record.branch }}
+
{{ balance.record.branch }}
} @if (isForm60 && form60) { -
{{ form60.branch }}
+
{{ form60.branch }}
} @if (isForm15gh && form15gh) { -
{{ form15gh.record.branch }}
+
{{ form15gh.record.branch }}
} @if (isHypothecation && hypothecation) { -
{{ hypothecation.branch }}
+
{{ hypothecation.branch }}
} @if (isNoc && noc) { -
{{ noc.branch }}
+
{{ noc.branch }}
}
@@ -700,4 +700,4 @@
-
+
\ No newline at end of file diff --git a/src/app/letter-mgmt/certificates/certificates.shared.scss b/src/app/letter-mgmt/certificates/certificates.shared.scss index ed8bea4d2..fc05c778c 100644 --- a/src/app/letter-mgmt/certificates/certificates.shared.scss +++ b/src/app/letter-mgmt/certificates/certificates.shared.scss @@ -505,3 +505,72 @@ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; 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; + } +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html index 417b1525d..07b30a3e3 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html @@ -11,26 +11,60 @@
-

Form 121

-
TDS-waiver declarations for FD / RD interest income β€” Form 121.
+

{{ statute.form }}

+
+ @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. + } +
+
+ @if (viewMode === 'demo') { + + } @else { + + } +
- + @if (viewMode === 'real') { + + } + + @if (viewMode === 'demo') { +
-
{{ count15G() | number }}
-
Form 121 (under 60)
+
{{ countGeneralBand() | number }}
+
General band (under 60)
-
{{ count15H() | number }}
-
Form 121 (60+)
+
{{ countSeniorBand() | number }}
+
Senior citizen band (60+)
@@ -47,6 +81,22 @@
Projected Interest
+
+ +
+
β‚Ή{{ totalTdsSaved() | number: '1.0-0' }}
+
TDS Saved
+
+
+ @if (countIneligible() > 0) { +
+ +
+
{{ countIneligible() | number }}
+
Not Eligible
+
+
+ }
@@ -60,11 +110,11 @@
- - + + +
@@ -77,7 +127,15 @@
- @if (search || kindFilter || statusFilter) { +
+ + +
+ + @if (search || bandFilter || statusFilter || ineligibleOnly) { }
@@ -89,54 +147,92 @@ Customer Β· PAN - Form Type + Form Β· Band Age Branch - Deposit Principal - Est. Interest ({{ records[0]?.fy }}) + Est. Interest + Threshold + TDS Payable + TDS Saved + Settlement Status Actions - @for (r of filteredRecords; track r.customerId) { - + @for (row of filteredRows; track row.record.customerId) { + -
{{ r.customerName }}
-
{{ r.customerId }} Β· PAN {{ r.customerPan }}
-
DOB {{ r.dob | date: 'd MMM y' }}
+
{{ row.record.customerName }}
+
{{ row.record.customerId }} Β· PAN {{ row.record.customerPan }}
+
DOB {{ row.record.dob | date: 'd MMM y' }}
- Form 121 ({{ formKind(r) }}) + {{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }} +
{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}
- {{ age(r.dob) }} yrs - {{ r.branch }} - β‚Ή{{ r.depositPrincipal | number: '1.0-0' }} + {{ row.age }} yrs + {{ row.record.branch }} - β‚Ή{{ r.estimatedInterest | number: '1.0-0' }} + β‚Ή{{ row.record.estimatedInterest | number: '1.0-0' }} + + β‚Ή{{ row.schedule.threshold | number: '1.0-0' }} + + @if (row.schedule.totalTds > 0) { + β‚Ή{{ row.schedule.totalTds | number: '1.0-0' }} +
@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%
+ } @else { + β€” + } + + + @if (row.tdsSaved > 0) { + β‚Ή{{ row.tdsSaved | number: '1.0-0' }} + } @else { + β€” + } + + + + {{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }} + +
product-driven
- @if (r.status === 'FILED') { + @if (row.record.status === 'FILED') { Filed - @if (r.filedOn) { -
{{ r.filedOn | date: 'd MMM y' }}
+ @if (row.record.filedOn) { +
{{ row.record.filedOn | date: 'd MMM y' }}
} } @else { Pending } + @if (!row.eligibility.eligible) { +
+ Not eligible +
+ }
+
+ + @if (isExpanded(row)) { + + + @if (!row.eligibility.eligible) { +
+ +
+ Declaration cannot be accepted +
    + @for (reason of row.eligibility.reasons; track reason) { +
  • {{ reason }}
  • + } +
+
+
+ } + @for (note of row.eligibility.unassessed; track note) { +
+ +
{{ note }}
+
+ } + @for (note of row.schedule.notes; track note) { +
+ +
{{ note }}
+
+ } + + + + + + + + + + + + + + @for (line of row.schedule.lines; track line.quarter) { + + + + + + + + + } + + + + + + + + + +
QuarterInterestCumulativeLiability to dateTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + threshold crossed + } + @if (line.declarationActive) { + Form 121 active + } + β‚Ή{{ line.interest | number: '1.0-0' }}β‚Ή{{ line.cumInterest | number: '1.0-0' }}β‚Ή{{ line.cumLiability | number: '1.0-0' }} + @if (line.tds !== 0) { + β‚Ή{{ line.tds | number: '1.0-0' }} + } @else { + β€” + } + {{ line.dueDate }}
Total + β‚Ή{{ row.schedule.totalInterest | number: '1.0-0' }} + + β‚Ή{{ row.schedule.totalTds | number: '1.0-0' }} +
+ + @if (row.schedule.shieldedInterest > 0) { +
+ β‚Ή{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of + the declaration β€” reportable in the quarterly {{ statute.statement }} return. +
+ } + + + } } - @if (filteredRecords.length === 0) { + @if (filteredRows.length === 0) { - + No declarations match the current filters. @@ -165,4 +351,5 @@
+} diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts index 839a9fb6f..9ab9b9b5d 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts @@ -21,9 +21,31 @@ import { } from '../certificate-preview-dialog/certificate-preview-dialog.component'; import { downloadForm15ghPdf } from '../certificate-pdf'; import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service'; +import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component'; -export type Form15Kind = '15G' | '15H'; +import { + 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'; +/** Which deduction trigger the payee falls under. Age-based, still. */ +export type ThresholdBand = 'GENERAL' | 'SENIOR'; export interface Form15ghRecord { customerId: string; @@ -32,18 +54,46 @@ export interface Form15ghRecord { customerAddress: string; dob: string; branch: string; - /** Aggregate interest the bank will pay this FY. */ + /** Aggregate interest the bank will pay this FY across the customer's time deposits. */ estimatedInterest: number; - /** Customer's total estimated income for the FY. */ + /** Customer's total estimated income for the FY, as declared by them. */ estimatedIncome: number; /** Sum of FD / RD principals tied to the declaration. */ depositPrincipal: number; fy: string; status: Form15Status; filedOn?: string; - /** "Yes / No" for: has any income tax been assessed for the customer - under the IT Act in the past six years? */ + /** + * "Yes / No" for: has any income tax been assessed for the customer under the + * IT Act in the past six years? + */ 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({ @@ -55,7 +105,8 @@ export interface Form15ghRecord { CommonModule, FormsModule, FaIconComponent, - MatTooltip + MatTooltip, + RealForm15ghViewComponent ] }) export class Form15ghComponent implements OnInit { @@ -63,40 +114,89 @@ export class Form15ghComponent implements OnInit { private snack = inject(MatSnackBar); private dscSign = inject(DscSignService); + /** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */ + viewMode: 'demo' | 'real' = 'demo'; + // ── Filters ── search = ''; - /** Which form. Empty = both. */ - kindFilter: '' | Form15Kind = ''; + /** Which deduction trigger. Empty = both bands. */ + bandFilter: '' | ThresholdBand = ''; statusFilter: '' | Form15Status = ''; + /** Show only declarations the branch cannot lawfully accept. */ + ineligibleOnly = false; - records: Form15ghRecord[] = []; + /** Which row's quarter-wise breakdown is open. */ + expandedId: string | null = null; + + rows: Form15ghRow[] = []; ngOnInit(): void { - this.records = seedForm15gh(); + // Derived once. The template reads these many times per change-detection + // 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)); } - /** Compute age in years from DoB. */ - age(dob: string): number { - if (!dob) return 0; - const today = new Date(); - const birth = new Date(dob); - let age = today.getFullYear() - birth.getFullYear(); - const m = today.getMonth() - birth.getMonth(); - if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--; - return age; + private buildRow(record: Form15ghRecord): 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), + // 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 + }) + }; } - /** 15H once 60+, 15G otherwise. */ - formKind(r: Form15ghRecord): Form15Kind { - return this.age(r.dob) >= 60 ? '15H' : '15G'; - } - - get filteredRecords(): Form15ghRecord[] { + get filteredRows(): Form15ghRow[] { const q = this.search.trim().toLowerCase(); - return this.records.filter((r) => { - if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false; - if (this.statusFilter && r.status !== this.statusFilter) return false; - if (!q) return true; + 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) || @@ -105,32 +205,67 @@ export class Form15ghComponent implements OnInit { }); } - count15G(): number { - return this.records.filter((r) => this.formKind(r) === '15G').length; + countGeneralBand(): number { + return this.rows.filter((r) => r.band === 'GENERAL').length; } - count15H(): number { - return this.records.filter((r) => this.formKind(r) === '15H').length; + countSeniorBand(): number { + return this.rows.filter((r) => r.band === 'SENIOR').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 { - return this.records.filter((r) => r.status === 'FILED').length; + return this.rows.filter((r) => r.record.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 { - return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0); + return this.filteredRows.reduce((s, r) => s + r.record.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 { this.search = ''; - this.kindFilter = ''; + this.bandFilter = ''; this.statusFilter = ''; + this.ineligibleOnly = false; } - viewCertificate(r: Form15ghRecord): void { + toggleExpanded(row: Form15ghRow): 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 = { kind: 'form15gh', - form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) } + form15gh: { record: row.record, kind: row.kind, age: row.age } }; this.dialog.open(CertificatePreviewDialogComponent, { width: '880px', @@ -142,20 +277,34 @@ export class Form15ghComponent implements OnInit { }); } - downloadCertificate(r: Form15ghRecord): void { - const kind = this.formKind(r); + downloadCertificate(row: Form15ghRow): void { + const r = row.record; + // 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( { - title: `Form 121 (${kind}) Declaration`, + title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`, reference: `${r.customerId} Β· ${r.customerName} Β· ${r.fy}`, filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf` }, - (dsc) => downloadForm15ghPdf(r, kind, dsc) + (dsc) => downloadForm15ghPdf(r, row.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[] { return [ { @@ -165,86 +314,109 @@ export function seedForm15gh(): Form15ghRecord[] { customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', dob: '1985-02-14', branch: 'Mumbai β€” Andheri (E)', - estimatedInterest: 38500, - estimatedIncome: 850000, - depositPrincipal: 500000, - fy: 'FY 2025-26', - status: 'FILED', - filedOn: '2025-04-10', - assessed: true + estimatedInterest: 38_500, + // Over the basic exemption limit β€” not eligible for 15G. + estimatedIncome: 850_000, + depositPrincipal: 500_000, + fy: 'FY 2026-27', + status: 'PENDING', + assessed: true, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' }, { customerId: 'CUST556677', customerName: 'Karthik Rao', - customerPan: 'PQRSR2233Z', + customerPan: 'PQRPR2233Z', customerAddress: '24, MG Road, Bengaluru 560001', dob: '1972-09-04', branch: 'Bengaluru β€” MG Road', - estimatedInterest: 52800, - estimatedIncome: 320000, - depositPrincipal: 700000, - fy: 'FY 2025-26', + estimatedInterest: 52_800, + estimatedIncome: 320_000, + depositPrincipal: 700_000, + fy: 'FY 2026-27', status: 'PENDING', - assessed: false + assessed: false, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' }, { customerId: 'CUST667788', customerName: 'Lakshmi Naidu', - customerPan: 'XYZAN7788Q', + customerPan: 'XYZPN7788Q', customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', dob: '1958-06-22', branch: 'Hyderabad β€” Banjara Hills', - estimatedInterest: 88200, - estimatedIncome: 410000, - depositPrincipal: 1200000, - fy: 'FY 2025-26', + estimatedInterest: 88_200, + estimatedIncome: 410_000, + depositPrincipal: 1_200_000, + fy: 'FY 2026-27', status: 'FILED', - filedOn: '2025-04-15', - assessed: true + filedOn: '2026-04-15', + assessed: true, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' }, { customerId: 'CUST334455', customerName: 'Anita Verma', - customerPan: 'EFGHV5678A', + customerPan: 'EFGPV5678A', customerAddress: 'B-12, Connaught Pl, New Delhi 110001', dob: '1962-11-30', branch: 'New Delhi β€” Connaught Place', - estimatedInterest: 64500, - estimatedIncome: 280000, - depositPrincipal: 900000, - fy: 'FY 2025-26', + estimatedInterest: 64_500, + estimatedIncome: 280_000, + depositPrincipal: 900_000, + fy: 'FY 2026-27', status: 'FILED', - filedOn: '2025-04-05', - assessed: false + filedOn: '2026-04-05', + assessed: false, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual' }, { customerId: 'CUST990011', customerName: 'Arjun Banerjee', - customerPan: 'TUVWB4455F', + customerPan: 'TUVPB4455F', customerAddress: '42, Park Street, Kolkata 700016', dob: '1991-07-19', branch: 'Kolkata β€” Park Street', - estimatedInterest: 14200, - estimatedIncome: 620000, - depositPrincipal: 200000, - fy: 'FY 2025-26', + estimatedInterest: 14_200, + // Over the basic exemption limit β€” not eligible for 15G. + estimatedIncome: 620_000, + depositPrincipal: 200_000, + fy: 'FY 2026-27', status: 'PENDING', - assessed: true + assessed: true, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly' }, { customerId: 'CUST330044', customerName: 'Madhavi Singh', - customerPan: 'TUVWS3344K', + customerPan: 'TUVPS3344K', customerAddress: 'D-5, Greater Kailash, New Delhi 110048', dob: '1955-03-08', branch: 'New Delhi β€” Connaught Place', - estimatedInterest: 145000, - estimatedIncome: 320000, - depositPrincipal: 2000000, - fy: 'FY 2025-26', + // A 15H declarant faces no ceiling on interest β€” only a nil liability. + estimatedInterest: 145_000, + estimatedIncome: 320_000, + depositPrincipal: 2_000_000, + fy: 'FY 2026-27', status: 'FILED', - filedOn: '2025-04-02', - assessed: false + filedOn: '2026-04-02', + 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' } ]; } diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html new file mode 100644 index 000000000..bc34a7490 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html @@ -0,0 +1,329 @@ + + +@if (loading) { +
+ +
Loading live declarations and accounts from Fineract…
+
+} @else if (loadError) { +
+ +
Could not load real data from Fineract. Check connection.
+
+} @else { + +
+
+ +
+
{{ countGeneralBand() | number }}
+
General band (under 60)
+
+
+
+ +
+
{{ countSeniorBand() | number }}
+
Senior citizen band (60+)
+
+
+
+ +
+
{{ countFiled() | number }}
+
Filed this FY
+
+
+
+ +
+
β‚Ή{{ totalInterest() | number: '1.0-0' }}
+
Projected Interest
+
+
+
+ +
+
β‚Ή{{ totalTdsSaved() | number: '1.0-0' }}
+
TDS Saved
+
+
+ @if (countIneligible() > 0) { +
+ +
+
{{ countIneligible() | number }}
+
Not Eligible
+
+
+ } +
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (search || bandFilter || statusFilter || ineligibleOnly) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.row.record.customerId) { + + + + + + + + + + + + + + + @if (isExpanded(row)) { + + + + } + } + @if (filteredRows.length === 0) { + + + + } + +
Customer Β· PANForm Β· BandAgeBranchEst. InterestThresholdTDS PayableTDS SavedSettlementStatusActions
+
{{ row.row.record.customerName }}
+
{{ row.row.record.customerId }} Β· PAN {{ row.row.record.customerPan || 'β€”' }}
+
DOB {{ row.row.record.dob | date: 'd MMM y' }}
+
+ + {{ row.row.form === '121' ? 'Form 121' : 'Form ' + row.row.form }} + +
{{ row.row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}
+
{{ row.row.age }} yrs{{ row.row.record.branch || 'β€”' }} + β‚Ή{{ row.row.record.estimatedInterest | number: '1.0-0' }} + β‚Ή{{ row.row.schedule.threshold | number: '1.0-0' }} + @if (row.row.schedule.totalTds > 0) { + β‚Ή{{ row.row.schedule.totalTds | number: '1.0-0' }} +
@ {{ row.row.schedule.rateApplied * 100 | number: '1.0-0' }}%
+ } @else { + β€” + } +
+ @if (row.row.tdsSaved > 0) { + β‚Ή{{ row.row.tdsSaved | number: '1.0-0' }} + } @else { + β€” + } + + + {{ row.row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }} + +
product-driven
+
+ @if (row.row.record.status === 'FILED') { + Filed + @if (row.row.record.filedOn) { +
{{ row.row.record.filedOn | date: 'd MMM y' }}
+ } + } @else { + Pending + } + @if (!row.row.eligibility.eligible) { +
+ Not eligible +
+ } +
+
+ + + +
+
+ @if (!row.row.eligibility.eligible) { +
+ +
+ Declaration cannot be accepted +
    + @for (reason of row.row.eligibility.reasons; track reason) { +
  • {{ reason }}
  • + } +
+
+
+ } + @for (note of row.row.eligibility.unassessed; track note) { +
+ +
{{ note }}
+
+ } + @for (note of row.row.schedule.notes; track note) { +
+ +
{{ note }}
+
+ } + + + + + + + + + + + + + + @for (line of row.row.schedule.lines; track line.quarter) { + + + + + + + + + } + + + + + + + + + +
QuarterInterestCumulativeLiability to dateTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + threshold crossed + } + @if (line.declarationActive) { + Form 121 active + } + β‚Ή{{ line.interest | number: '1.0-0' }}β‚Ή{{ line.cumInterest | number: '1.0-0' }}β‚Ή{{ line.cumLiability | number: '1.0-0' }} + @if (line.tds !== 0) { + β‚Ή{{ line.tds | number: '1.0-0' }} + } @else { + β€” + } + {{ line.dueDate }}
Total + β‚Ή{{ row.row.schedule.totalInterest | number: '1.0-0' }} + + β‚Ή{{ row.row.schedule.totalTds | number: '1.0-0' }} +
+ + @if (row.row.schedule.shieldedInterest > 0) { +
+ β‚Ή{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of + the declaration β€” reportable in the quarterly {{ statute.statement }} return. +
+ } +
+ + No active clients match the current filters. +
+
+
+} diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts new file mode 100644 index 000000000..1793ce61c --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts @@ -0,0 +1,502 @@ +/** + * 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(); + + 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>(); + private clientKycCache = new Map>(); + private clientAccountsCache = new Map>(); + + 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 { + 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 { + 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 { + 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 { + 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 { + const id = String(accountSummary.id); + const code = accountSummary.depositType?.code ?? ''; + let call: Observable; + 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) + ); + } +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts new file mode 100644 index 000000000..636979647 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts @@ -0,0 +1,766 @@ +/** + * 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 { + return { fy: FY, dateOfBirth: '1985-02-14', pan: 'ABCPM1234E', resident: true, ...overrides }; +} + +/** 60+ for FY 2026-27. */ +function senior(overrides: Partial = {}): 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 = {}) => general({ fy: OLD_FY, ...o }); + const oldSenior = (o: Partial = {}) => 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); + }); +}); diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts new file mode 100644 index 000000000..32ee84d6d --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts @@ -0,0 +1,1133 @@ +/** + * 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/. + */ + +/** + * TDS on deposit interest, and the effect of a payee's declaration on it. + * + * The governing law changed on 01 April 2026. Up to FY 2025-26 the charge sat in + * section 194A of the Income-tax Act 1961 and the declaration was Form 15G or + * 15H; from FY 2026-27 the charge is section 393(1) of the Income Tax Act 2025 + * and a single Form 121 replaces both. Thresholds and rates carried across + * unchanged. Every provision reference lives in `FY_TAX_CONFIG` so a screen or a + * return always quotes the law that applied to the year it is showing. + * + * Two settlement modes, because deposit products run both: + * + * QUARTERLY β€” deduct at each quarter end, alongside the quarterly return cycle. + * ANNUAL β€” one settlement at financial year end, for products that post + * interest annually. + * + * The mode follows the product's interest-posting frequency, never a customer + * preference: the charge falls when interest is credited or paid, whichever is + * earlier, so the branch has no discretion to defer. See `tdsModeFor`. + * + * Both modes are built from one year-to-date liability ladder, so for an + * unchanging customer profile they add up to exactly the same rupee figure. + * `reconcile` reports the single case where they legitimately do not. + */ + +/** + * Which Act governs a financial year. + * + * The Income-tax Act 1961 gave way to the Income Tax Act 2025 with effect from + * 01 April 2026, and the change is not cosmetic for this module: the two + * declaration forms merged into one and the eligibility test changed with them. + * A book that spans the changeover has to apply the right rules to each year, + * which is why the regime is carried per year rather than assumed. + */ +export type TaxRegime = 'ITA_1961' | 'ITA_2025'; + +/** + * The declaration a payee files to stop tax being deducted. + * + * Under the 1961 Act there were two, split by age: 15G for the under-60s and 15H + * for senior citizens. From FY 2026-27 both were replaced by a single Form 121 + * open to residents of any age. + */ +export type DeclarationForm = '15G' | '15H' | '121'; + +/** + * Retained for the screens, which still speak of the 15G/15H split when showing + * a pre-2026 year. + */ +export type Form15Kind = '15G' | '15H'; + +export type TdsMode = 'QUARTERLY' | 'ANNUAL'; +export type Quarter = 1 | 2 | 3 | 4; + +/** + * Rates, limits and statutory references for one financial year. + * + * Every figure here moves with the annual Act, so it is table-driven rather than + * inlined at the call sites β€” a new year is a new entry, not a code change. The + * citations travel with the figures for the same reason: the screens and the + * returns must quote the provision that actually applied to the year on display. + */ +export interface FyTaxConfig { + /** Canonical label, e.g. '2026-27'. */ + fy: string; + regime: TaxRegime; + /** The provision charging TDS on interest, for labels and audit trails. */ + interestTdsSection: string; + /** The provision granting the below-threshold exemption. */ + thresholdSection: string; + /** The provision charging the higher rate where PAN is not furnished. */ + noPanSection: string; + /** The provision on consequences of failing to deduct or pay. */ + defaultSection: string; + /** The declaration form in force. */ + declarationForm: DeclarationForm; + /** The rule the declaration is made under. */ + declarationRule: string; + /** Provision the declaration itself is filed under. */ + declarationSection: string; + /** The quarterly TDS return the deduction is reported in. */ + tdsStatementForm: string; + /** Rule governing that return. */ + tdsStatementRule: string; + /** The certificate issued to the deductee. */ + certificateForm: string; + /** How often the payer uploads the consolidated declaration statement. */ + declarationStatementFrequency: 'MONTHLY' | 'QUARTERLY'; + /** + * Day of the following month or quarter by which that statement is due. + * Under the 2025 Act the upload became monthly, due on the 7th. + */ + declarationStatementDueDay: number; + /** Deduction trigger for a payee below 60. */ + thresholdGeneral: number; + /** Deduction trigger for a senior citizen β€” 60 at any time during the year. */ + thresholdSenior: number; + /** Rate where a valid PAN is on record. */ + rate: number; + /** Higher rate where PAN is absent or invalid (section 206AA). */ + rateNoPan: number; + /** + * Basic exemption limit. Under the 1961 Act a 15G declarant's estimated income + * *and* their aggregate interest both had to stay within it. + */ + basicExemption: number; + /** + * Total income up to which rebate leaves tax at nil. This is the only ceiling + * for a 15H declarant, and from FY 2026-27 the only ceiling for anyone. + */ + nilTaxCeiling: number; + /** + * Multiple to round each cumulative liability to. 1 = nearest rupee, which is + * what deposit systems generally deduct at. + */ + roundTo: number; + /** Failure to deduct β€” interest per month or part thereof. */ + lateDeductionRate: number; + /** Deducted but not deposited β€” interest per month or part thereof. */ + lateDepositRate: number; +} + +/** + * Statutory figures for the years this book covers, on the new tax regime. + * + * For a customer who has opted out onto the old regime, `basicExemption` and + * `nilTaxCeiling` are lower and vary by age; pass an override through + * `TdsContext.config`. + * + * Thresholds and rates did not change at the 2026 changeover β€” β‚Ή50,000, + * β‚Ή1,00,000 for senior citizens, 10%, and 20% without PAN carried straight + * across. What changed is the declaration and the return it is reported in. + */ +export const FY_TAX_CONFIG: Record = { + '2025-26': { + fy: '2025-26', + regime: 'ITA_1961', + interestTdsSection: 'section 194A', + thresholdSection: 'section 194A(3)(i)', + noPanSection: 'section 206AA', + defaultSection: 'section 201(1A)', + declarationForm: '15G', + declarationRule: 'Rule 29C', + declarationSection: 'section 197A', + tdsStatementForm: 'Form 26Q', + tdsStatementRule: 'Rule 31A', + certificateForm: 'Form 16A', + declarationStatementFrequency: 'QUARTERLY', + declarationStatementDueDay: 15, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + }, + '2026-27': { + fy: '2026-27', + regime: 'ITA_2025', + // Old section 194A was folded into the omnibus TDS section of the 2025 Act. + interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]', + thresholdSection: 'section 393(4) [Table Sl. 7]', + // 206AA and 206CC merged here. The reduced 5% limb applies only to Sl. 8(ii) + // and 8(v), so interest stays at 20% without PAN. + noPanSection: 'section 397(2)', + defaultSection: 'section 398', + declarationForm: '121', + declarationRule: 'Rule 211 of the Income-tax Rules, 2026', + declarationSection: 'section 393(6)', + tdsStatementForm: 'Form 140', + tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026', + certificateForm: 'Form 131', + // The consolidated statement of declarations received moved from a quarterly + // upload to a monthly one, due on the 7th of the following month. + declarationStatementFrequency: 'MONTHLY', + declarationStatementDueDay: 7, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + } +}; + +// ─────────────────────── Unique Identification Number ─────────────────────── + +/** + * A Form 121 UIN is 26 characters: + * + * D + 9 numeric digits + 6 digits of the tax year + the payer's 10-character TAN + * + * The payer allots one to every declaration it accepts and carries it into both + * the monthly consolidated statement and the deductee annexure of the quarterly + * return, which is how the department ties a nil-deduction back to a declaration. + */ +export const UIN_LENGTH = 26; + +/** The 6-digit tax-year component of a UIN: start year and end year, e.g. 202627. */ +export function uinTaxYearPart(fy: string): string { + const year = normaliseFy(fy); + return `${year.slice(0, 4)}${year.slice(5, 7)}`; +} + +/** + * Builds a UIN from its parts. + * + * `sequence` is the payer's own running number for the year; it is zero-padded to + * nine digits rather than randomised so a branch can reconcile its register + * against the portal by eye, and so the same declaration always yields the same + * UIN on a re-run. + */ +export function buildUin(sequence: number, fy: string, tan: string): string { + const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0'); + const normalisedTan = (tan || '').toUpperCase().replace(/[^A-Z0-9]/g, '').padEnd(10, 'X').slice(0, 10); + return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`; +} + +export interface UinParts { + sequence: number; + fy: string; + tan: string; +} + +/** Splits a UIN back into its parts, or null if it is not well formed. */ +export function parseUin(uin: string): UinParts | null { + const u = (uin || '').toUpperCase(); + if (!/^D\d{9}\d{6}[A-Z0-9]{10}$/.test(u)) { + return null; + } + const startYear = Number(u.slice(10, 14)); + return { + sequence: Number(u.slice(1, 10)), + fy: `${startYear}-${u.slice(14, 16)}`, + tan: u.slice(16) + }; +} + +export function isWellFormedUin(uin: string): boolean { + return parseUin(uin) !== null; +} + +/** + * When the payer must have uploaded the consolidated statement covering a + * declaration accepted on the given date. + * + * Under the 2025 Act the upload is monthly and falls due on the 7th of the + * following month. Under the 1961 Act it ran with the quarter. + */ +export function declarationStatementDueDate(acceptedOn: string, fy: string): string { + const cfg = FY_TAX_CONFIG[normaliseFy(fy)]; + if (!cfg) { + throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`); + } + const day = `${cfg.declarationStatementDueDay}`.padStart(2, '0'); + + if (cfg.declarationStatementFrequency === 'MONTHLY') { + const year = Number(acceptedOn.slice(0, 4)); + const month = Number(acceptedOn.slice(5, 7)); + const nextYear = month === 12 ? year + 1 : year; + const nextMonth = month === 12 ? 1 : month + 1; + return `${nextYear}-${`${nextMonth}`.padStart(2, '0')}-${day}`; + } + + // Quarterly: the month after the quarter in which it was accepted, except the + // last quarter, which the 1961 Act ran to 30 April. + const quarter = quarterOf(acceptedOn); + const start = Number(normaliseFy(fy).slice(0, 4)); + switch (quarter) { + case 1: + return `${start}-07-${day}`; + case 2: + return `${start}-10-${day}`; + case 3: + return `${start + 1}-01-${day}`; + default: + return `${start + 1}-04-30`; + } +} + +/** The declaration form in force for a year. */ +export function declarationFormFor(fy: string, dateOfBirth?: string): DeclarationForm { + const cfg = FY_TAX_CONFIG[normaliseFy(fy)]; + if (!cfg) { + throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`); + } + if (cfg.regime === 'ITA_2025') { + // One form for everybody, whatever their age. + return '121'; + } + return dateOfBirth && isSeniorInFy(dateOfBirth, fy) ? '15H' : '15G'; +} + +/** One interest credit posted to a deposit. */ +export interface InterestCredit { + /** ISO date the interest was paid or credited β€” whichever came first. */ + date: string; + amount: number; + accountNo?: string; +} + +/** + * A Form 121 declaration on the branch's record. + * + * `acceptedOn` is the operative date: interest credited on or after it is paid + * without deduction. Interest credited before it was already liable and stays + * deducted. + */ +export interface Declaration { + kind: Form15Kind; + acceptedOn: string; + fy: string; + /** + * Unique Identification Number the branch allots on acceptance, carried into + * the consolidated statement of declarations filed with the department. + */ + uin?: string; +} + +export interface TdsContext { + /** 'FY 2026-27' and '2026-27' are both accepted. */ + fy: string; + dateOfBirth: string; + pan: string; + /** A declaration is only available to a resident payee. */ + resident?: boolean; + declaration?: Declaration | null; + /** Overrides the FY table β€” used for old-regime customers. */ + config?: Partial; +} + +// ───────────────────────── Financial year plumbing ───────────────────────── + +/** Strips the 'FY ' prefix the screens use and validates the shape. */ +export function normaliseFy(fy: string): string { + const m = /(\d{4})-(\d{2,4})/.exec((fy || '').trim()); + if (!m) { + throw new Error(`Unrecognised financial year: "${fy}"`); + } + const start = Number(m[1]); + const endShort = m[2].length === 4 ? m[2].slice(2) : m[2]; + return `${start}-${endShort}`; +} + +export function fyStart(fy: string): string { + return `${normaliseFy(fy).slice(0, 4)}-04-01`; +} + +export function fyEnd(fy: string): string { + return `${Number(normaliseFy(fy).slice(0, 4)) + 1}-03-31`; +} + +/** + * The financial year an ISO date belongs to, as '2026-27'. + * + * April opens the year, so January to March belong to the year that started in + * the previous calendar year. + */ +export function fyOf(isoDate: string): string { + const year = Number(isoDate.slice(0, 4)); + const month = Number(isoDate.slice(5, 7)); + const start = month >= 4 ? year : year - 1; + return `${start}-${`${(start + 1) % 100}`.padStart(2, '0')}`; +} + +export const QUARTER_LABEL: Record = { + 1: 'Q1 (Apr–Jun)', + 2: 'Q2 (Jul–Sep)', + 3: 'Q3 (Oct–Dec)', + 4: 'Q4 (Jan–Mar)' +}; + +/** Statutory due date for the quarterly TDS return. */ +export const QUARTER_DUE_DATE: Record = { + 1: '31 Jul', + 2: '31 Oct', + 3: '31 Jan', + 4: '31 May' +}; + +/** Which quarter of the Indian financial year an ISO date falls in. */ +export function quarterOf(isoDate: string): Quarter { + const month = Number(isoDate.slice(5, 7)); + if (month >= 4 && month <= 6) { + return 1; + } + if (month >= 7 && month <= 9) { + return 2; + } + if (month >= 10 && month <= 12) { + return 3; + } + return 4; +} + +/** Last day of the given quarter of the given financial year. */ +export function quarterEnd(quarter: Quarter, fy: string): string { + const start = Number(normaliseFy(fy).slice(0, 4)); + switch (quarter) { + case 1: + return `${start}-06-30`; + case 2: + return `${start}-09-30`; + case 3: + return `${start}-12-31`; + default: + return `${start + 1}-03-31`; + } +} + +/** + * Age the customer attains during the financial year. + * + * Deliberately not age as at today. For the senior-citizen threshold the + * test is whether the payee is 60 at any time during the year, so a customer + * turning 60 in March gets the senior limit from the April credit onwards. + */ +export function ageInFy(dateOfBirth: string, fy: string): number { + if (!dateOfBirth) { + return 0; + } + const born = new Date(`${dateOfBirth}T00:00:00Z`); + const asAt = new Date(`${fyEnd(fy)}T00:00:00Z`); + let age = asAt.getUTCFullYear() - born.getUTCFullYear(); + const m = asAt.getUTCMonth() - born.getUTCMonth(); + if (m < 0 || (m === 0 && asAt.getUTCDate() < born.getUTCDate())) { + age--; + } + return age; +} + +export function isSeniorInFy(dateOfBirth: string, fy: string): boolean { + return ageInFy(dateOfBirth, fy) >= 60; +} + +/** 15H once the customer is 60 during the year, 15G otherwise. */ +export function formKindFor(dateOfBirth: string, fy: string): Form15Kind { + return isSeniorInFy(dateOfBirth, fy) ? '15H' : '15G'; +} + +// ───────────────────────────── PAN validation ────────────────────────────── + +/** + * The fourth character of a PAN encodes the holder type. Only an individual (P) + * or a HUF (H) can give a Form 121 declaration; the rest are entities. + */ +const PAN_HOLDER_TYPES = 'ABCFGHJLPT'; + +export function isWellFormedPan(pan: string): boolean { + const p = (pan || '').toUpperCase(); + return /^[A-Z]{5}[0-9]{4}[A-Z]$/.test(p) && PAN_HOLDER_TYPES.includes(p[3]); +} + +export function isIndividualPan(pan: string): boolean { + const p = (pan || '').toUpperCase(); + return isWellFormedPan(p) && (p[3] === 'P' || p[3] === 'H'); +} + +// ──────────────────── Which accounts, and whose PAN ──────────────────────── + +/** + * The shape this module needs from a deposit account. Deliberately loose so it + * can be fed straight from a Fineract account payload. + */ +export interface TdsAccountRef { + id?: number; + accountNo?: string; + /** Fineract `depositType.code`, e.g. 'depositAccountType.fixedDeposit'. */ + depositTypeCode?: string; + /** RBI account status captured on the stepper: SINGLE, JOINT, MINOR, … */ + accountStatus?: string; + /** The account holder for a single-name account. */ + clientId?: number | null; + /** First-named holder on a joint account. */ + primaryClientId?: number | null; + /** Guardian operating a minor's account. */ + guardianClientId?: number | null; + jointHolders?: { clientId: number }[]; + /** Fineract `interestPostingPeriodType` β€” object, code or id. */ + interestPostingPeriodType?: { id?: number; code?: string; value?: string } | string | number | null; +} + +/** An account together with the interest credited to it. */ +export interface TdsAccountInput extends TdsAccountRef { + credits: InterestCredit[]; +} + +/** + * Whether interest on this account attracts TDS at all. + * + * The charge bites on *time deposits* under either Act. Interest on a savings + * bank account is + * outside it β€” the customer claims the 80TTA / 80TTB deduction instead β€” so + * savings interest must be kept out of the aggregate as well as out of the + * deduction. Folding it in would push customers over the trigger who were never + * meant to cross it. + */ +export function isTdsApplicable(account: TdsAccountRef): boolean { + const code = (account.depositTypeCode || '').toLowerCase(); + return code.includes('fixeddeposit') || code.includes('recurringdeposit'); +} + +/** + * The client in whose PAN the tax must be deducted. + * + * A joint deposit is reported against the first-named holder: the bank's + * obligation runs to one PAN, and the return carries one deductee for the account. + * Any `sharePercentage` on the account governs operational matters β€” signing + * authority, closure proceeds, insurance cover β€” and must not be used to split + * interest for tax, which would produce two deductees for one deposit. + * + * A minor's interest is clubbed into the guardian's income under section + * 64(1A), so the deduction follows the guardian. + */ +export function tdsDeducteeOf(account: TdsAccountRef): number | null { + const status = (account.accountStatus || '').toUpperCase(); + if (status === 'JOINT') { + return account.primaryClientId ?? account.clientId ?? null; + } + if (status === 'MINOR') { + return account.guardianClientId ?? account.clientId ?? null; + } + return account.clientId ?? account.primaryClientId ?? null; +} + +/** + * When the branch must settle tax on this account. + * + * Driven by the product's interest-posting frequency, because the charge falls + * when interest is credited or paid, whichever is earlier. A customer cannot + * elect to defer it β€” doing so puts the bank in default and attracts interest for + * every month or part of a month of delay. A customer who wants no deduction has + * two lawful routes: a declaration if they qualify, or a deposit product that + * posts interest annually. + * + * Monthly and half-yearly posting both settle on the quarterly cycle, which is + * what the quarterly return runs on. + */ +export function tdsModeFor(account: TdsAccountRef): TdsMode { + const raw = account.interestPostingPeriodType; + const text = typeof raw === 'object' && raw !== null ? `${raw.code ?? ''} ${raw.value ?? ''}` : `${raw ?? ''}`; + const s = text.toLowerCase(); + // 'biannual' contains 'annual' β€” test the compound word first or half-yearly + // products would silently settle once a year. + if (s.includes('biannual') || s.includes('bi-annual') || s.includes('semiannual')) { + return 'QUARTERLY'; + } + if (s.includes('annual')) { + return 'ANNUAL'; + } + return 'QUARTERLY'; +} + +/** + * Every interest credit that counts towards one customer's deduction aggregate. + * + * This is the one place rules 8 to 12 meet: savings interest is dropped, the + * deductee is resolved per account, and only the selected year is kept. The + * aggregate spans every branch, because a bank on core banking computes the + * threshold for the bank as a whole rather than branch by branch. + */ +export function collectTdsCredits(clientId: number, accounts: TdsAccountInput[], fy: string): InterestCredit[] { + const year = normaliseFy(fy); + const out: InterestCredit[] = []; + for (const account of accounts) { + if (!isTdsApplicable(account) || tdsDeducteeOf(account) !== clientId) { + continue; + } + for (const c of account.credits) { + if (fyOf(c.date) === year) { + out.push({ ...c, accountNo: c.accountNo ?? account.accountNo }); + } + } + } + return out.sort((a, b) => a.date.localeCompare(b.date)); +} + +// ─────────────────────────────── Eligibility ─────────────────────────────── + +export interface EligibilityInput { + /** Aggregate interest the bank expects to credit this year. */ + estimatedInterest: number; + /** The customer's estimated total income for the year, interest included. */ + estimatedIncome: number; + /** + * Whether the income figure is actually on record. The branch cannot compute + * it β€” income from outside the bank is only known if the customer declares + * it β€” so when it is missing the income limbs are reported as unassessed + * rather than silently passed. + */ + incomeOnRecord?: boolean; +} + +export interface EligibilityResult { + eligible: boolean; + /** The form actually in force for the year β€” '121' from FY 2026-27. */ + form: DeclarationForm; + /** + * The 15G/15H label, for screens showing a pre-2026 year. From FY 2026-27 the + * forms are merged, so this only reflects which threshold band the payee is in. + */ + kind: Form15Kind; + /** Why the declaration cannot be accepted, in the branch's words. */ + reasons: string[]; + /** Tests that could not be run because a figure was missing. */ + unassessed: string[]; +} + +/** + * Whether the payee's declaration can be accepted. + * + * The test depends on which Act governs the year, and the difference is real: + * + * Up to FY 2025-26 (1961 Act) β€” two forms, split by age. + * 15G, under 60: estimated total income must be within the basic exemption + * limit *and* aggregate interest must also be within it. Both limbs. + * 15H, 60 and over: the only test is a nil tax liability; there is no + * ceiling on the interest figure, so a senior citizen with several lakh + * of interest may still declare if rebate brings the tax to nil. + * + * From FY 2026-27 (2025 Act) β€” one Form 121 for residents of any age, and a + * single test: estimated tax on total income for the year must be nil. The + * interest limb is gone, so a young depositor with large interest but no + * liability is now eligible where a 15G would have failed them. + * + * No declaration is valid without PAN under either regime, and none is open to a + * non-resident. Form 121 tightened this further: the 15G/15H forms accepted PAN + * or Aadhaar, whereas Form 121 takes PAN only. + */ +export function checkEligibility(ctx: TdsContext, input: EligibilityInput): EligibilityResult { + const cfg = resolveConfig(ctx); + const form = declarationFormFor(ctx.fy, ctx.dateOfBirth); + const kind = formKindFor(ctx.dateOfBirth, ctx.fy); + const reasons: string[] = []; + const unassessed: string[] = []; + const label = form === '121' ? 'Form 121' : `Form ${form}`; + + if (!isWellFormedPan(ctx.pan)) { + reasons.push( + `PAN is missing or malformed β€” the declaration is void without a valid PAN (${cfg.declarationRule}).` + + (cfg.regime === 'ITA_2025' ? ' Form 121 accepts PAN only; Aadhaar is not a substitute.' : '') + ); + } else if (!isIndividualPan(ctx.pan)) { + reasons.push(`PAN belongs to an entity, not an individual or HUF β€” ${label} is not available to it.`); + } + + if (ctx.resident === false) { + reasons.push('Declaration is available only to a resident payee.'); + } + + const rupees = (n: number) => `β‚Ή${n.toLocaleString('en-IN')}`; + const incomeKnown = input.incomeOnRecord !== false; + + // The interest limb only ever applied to a 15G, and only under the 1961 Act. + const hasInterestLimb = cfg.regime === 'ITA_1961' && form === '15G'; + const incomeCeiling = hasInterestLimb ? cfg.basicExemption : cfg.nilTaxCeiling; + + if (!incomeKnown) { + unassessed.push( + `Estimated total income is not on record β€” the nil-liability test for ${label} was not run. The branch ` + + `cannot compute income earned outside the bank; the payee must declare it.` + ); + } else if (input.estimatedIncome > incomeCeiling) { + reasons.push( + hasInterestLimb + ? `Estimated total income ${rupees(input.estimatedIncome)} exceeds the basic exemption limit of ` + + `${rupees(cfg.basicExemption)}.` + : `Estimated total income ${rupees(input.estimatedIncome)} leaves a tax liability β€” ${label} requires the ` + + `liability for the year to be nil.` + ); + } + + if (hasInterestLimb && input.estimatedInterest > cfg.basicExemption) { + // The bank knows this figure without asking, so it is always assessable. + reasons.push( + `Aggregate interest ${rupees(input.estimatedInterest)} exceeds the basic exemption limit β€” a 15G declarant ` + + `must clear this limb as well as the income limb.` + ); + } + + return { eligible: reasons.length === 0, form, kind, reasons, unassessed }; +} + +// ──────────────────────── The year-to-date liability ──────────────────────── + +function resolveConfig(ctx: TdsContext): FyTaxConfig { + const fy = normaliseFy(ctx.fy); + const base = FY_TAX_CONFIG[fy]; + if (!base) { + throw new Error(`No tax configuration for FY ${fy}`); + } + return { ...base, ...ctx.config }; +} + +/** Rounds to the nearest multiple of `to`. */ +function roundToMultiple(value: number, to: number): number { + return to <= 1 ? Math.round(value) : Math.round(value / to) * to; +} + +/** + * TDS on the interest credited so far this year. + * + * The threshold is a *trigger*, not an allowance. Once aggregate interest for + * the year exceeds it, the whole of that interest becomes liable β€” not merely + * the excess. β‚Ή52,000 of interest attracts 10% of β‚Ή52,000, i.e. β‚Ή5,200, and not + * 10% of the β‚Ή2,000 above the line. + * + * Rounding is applied here, to the cumulative figure. That placement is what + * makes the quarterly schedule add up β€” see `quarterlySchedule`. + */ +export function cumulativeLiability(cumInterest: number, cumLiableInterest: number, ctx: TdsContext): number { + const cfg = resolveConfig(ctx); + const threshold = isSeniorInFy(ctx.dateOfBirth, ctx.fy) ? cfg.thresholdSenior : cfg.thresholdGeneral; + if (cumInterest <= threshold) { + return 0; + } + const rate = isWellFormedPan(ctx.pan) ? cfg.rate : cfg.rateNoPan; + return roundToMultiple(Math.max(0, cumLiableInterest) * rate, cfg.roundTo); +} + +// ────────────────────────────── The schedules ─────────────────────────────── + +export interface TdsQuarterLine { + quarter: Quarter; + label: string; + dueDate: string; + /** Interest credited during the quarter. */ + interest: number; + /** Interest credited from 01 April to the end of this quarter. */ + cumInterest: number; + /** Of that, the part not covered by a declaration. */ + cumLiableInterest: number; + /** Liability on the year to date, before crediting what is already deducted. */ + cumLiability: number; + /** + * Deducted in this quarter β€” the catch-up over what has already gone. A + * negative figure is an in-year adjustment following an interest reversal. + */ + tds: number; + /** Deducted from 01 April to the end of this quarter. */ + cumTds: number; + /** The aggregate crossed the deduction trigger during this quarter. */ + thresholdCrossedHere: boolean; + /** A declaration was on the branch's record by the end of this quarter. */ + declarationActive: boolean; +} + +export interface TdsSchedule { + mode: TdsMode; + fy: string; + kind: Form15Kind; + senior: boolean; + /** Rate actually applied β€” 20% where PAN is not on record. */ + rateApplied: number; + threshold: number; + totalInterest: number; + /** Interest not covered by a declaration. */ + liableInterest: number; + /** Interest paid gross because a declaration was on record. */ + shieldedInterest: number; + totalTds: number; + lines: TdsQuarterLine[]; + /** Anything the branch should read before filing. */ + notes: string[]; +} + +function emptyQuarters(): Record { + return { 1: 0, 2: 0, 3: 0, 4: 0 }; +} + +/** + * Buckets dated interest credits into the four quarters of the year. + * + * Credits outside the year are dropped rather than folded into Q4 β€” a stray + * date is a data error, not a Q4 posting. + */ +export function bucketByQuarter(credits: InterestCredit[], fy: string): Record { + const from = fyStart(fy); + const to = fyEnd(fy); + const out = emptyQuarters(); + for (const c of credits) { + if (c.date >= from && c.date <= to) { + out[quarterOf(c.date)] += c.amount; + } + } + return out; +} + +/** Splits each quarter's interest into shielded and liable halves. */ +function splitByDeclaration( + credits: InterestCredit[], + ctx: TdsContext +): { liable: Record; shielded: Record } { + const from = fyStart(ctx.fy); + const to = fyEnd(ctx.fy); + const decl = ctx.declaration; + const liable = emptyQuarters(); + const shielded = emptyQuarters(); + + for (const c of credits) { + if (c.date < from || c.date > to) { + continue; + } + const q = quarterOf(c.date); + // A declaration shields interest credited on or after the date the branch + // accepted it. It never reaches back over earlier credits. + if (decl && decl.acceptedOn && c.date >= decl.acceptedOn) { + shielded[q] += c.amount; + } else { + liable[q] += c.amount; + } + } + return { liable, shielded }; +} + +interface LadderStep { + quarter: Quarter; + interest: number; + cumInterest: number; + cumLiableInterest: number; + cumLiability: number; + declarationActive: boolean; +} + +/** + * The year-to-date liability at each quarter end. + * + * This single ladder is the source of both schedules. The quarterly mode reads + * every rung; the annual mode reads only the last. That is the whole reason the + * two agree β€” they are not two calculations, they are two samplings of one. + */ +function buildLadder(credits: InterestCredit[], ctx: TdsContext): LadderStep[] { + const { liable, shielded } = splitByDeclaration(credits, ctx); + const steps: LadderStep[] = []; + let cumInterest = 0; + let cumLiableInterest = 0; + + for (const q of [ + 1, + 2, + 3, + 4 + ] as Quarter[]) { + cumInterest += liable[q] + shielded[q]; + cumLiableInterest += liable[q]; + steps.push({ + quarter: q, + interest: liable[q] + shielded[q], + cumInterest, + cumLiableInterest, + cumLiability: cumulativeLiability(cumInterest, cumLiableInterest, ctx), + // Keyed off the acceptance date rather than off whether shielded interest + // happened to arrive: a quarter carrying only a reversal is still a + // quarter in which the declaration was on the branch's record. + declarationActive: + !!ctx.declaration && !!ctx.declaration.acceptedOn && quarterEnd(q, ctx.fy) >= ctx.declaration.acceptedOn + }); + } + return steps; +} + +function scheduleShell(mode: TdsMode, credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + const cfg = resolveConfig(ctx); + const senior = isSeniorInFy(ctx.dateOfBirth, ctx.fy); + const { liable, shielded } = splitByDeclaration(credits, ctx); + const sum = (r: Record) => r[1] + r[2] + r[3] + r[4]; + return { + mode, + fy: normaliseFy(ctx.fy), + kind: formKindFor(ctx.dateOfBirth, ctx.fy), + senior, + rateApplied: isWellFormedPan(ctx.pan) ? cfg.rate : cfg.rateNoPan, + threshold: senior ? cfg.thresholdSenior : cfg.thresholdGeneral, + totalInterest: sum(liable) + sum(shielded), + liableInterest: sum(liable), + shieldedInterest: sum(shielded), + totalTds: 0, + lines: [], + notes: [] + }; +} + +/** + * Deduct at each quarter end. + * + * Each quarter deducts the difference between the liability on the year to date + * and what has already been deducted: + * + * tds(n) = cumLiability(n) βˆ’ cumTds(nβˆ’1) + * + * Written that way the four quarters telescope: they sum to cumLiability(4) less + * cumLiability(0), which is precisely the liability on the whole year's + * interest. The identity survives rounding because it is the *cumulative* figure + * that gets rounded, so each quarter is a difference of two already rounded + * numbers. Rounding each quarter's own share independently is what makes naive + * implementations drift by a rupee or two a year. + * + * It also gets the threshold right for free. Nothing is deducted while the + * aggregate is under the trigger; in the quarter it is crossed, the full + * year-to-date liability falls due at once. That catch-up looks severe against + * the one quarter's interest, and it is what a branch actually deducts. + */ +export function quarterlySchedule(credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + const out = scheduleShell('QUARTERLY', credits, ctx); + const ladder = buildLadder(credits, ctx); + const decl = ctx.declaration; + + let cumTds = 0; + let frozen = false; + let prevCumInterest = 0; + + for (const step of ladder) { + // Once a declaration is operative the branch stops deducting. It does not + // refund what it has already remitted to the Government either β€” the + // customer reclaims that in their return, which is why cumTds freezes here + // rather than falling back to the now-lower liability. + if (decl && step.declarationActive) { + frozen = true; + } + + const target = frozen ? Math.max(cumTds, step.cumLiability) : step.cumLiability; + const tds = target - cumTds; + cumTds = target; + + out.lines.push({ + quarter: step.quarter, + label: QUARTER_LABEL[step.quarter], + dueDate: QUARTER_DUE_DATE[step.quarter], + interest: step.interest, + cumInterest: step.cumInterest, + cumLiableInterest: step.cumLiableInterest, + cumLiability: step.cumLiability, + tds, + cumTds, + thresholdCrossedHere: prevCumInterest <= out.threshold && step.cumInterest > out.threshold, + declarationActive: step.declarationActive + }); + prevCumInterest = step.cumInterest; + } + + out.totalTds = cumTds; + + if (decl && decl.acceptedOn && cumTds > 0) { + out.notes.push( + `β‚Ή${cumTds.toLocaleString('en-IN')} relates to interest credited before the declaration was accepted on ` + + `${decl.acceptedOn} and has been remitted to the Government. A declaration operates prospectively, so ` + + `the branch cannot reverse it β€” the customer recovers it in their return if the nil-liability basis holds.` + ); + } + return out; +} + +/** + * Defer to a single settlement at financial year end. + * + * This is the last rung of the same ladder, so with an unchanging profile it + * equals the sum of the quarterly figures by construction. The per-quarter lines + * still carry the interest and the liability as they accrued, with the whole + * deduction landing in Q4 β€” which is what the branch's Q4 return will show. + */ +export function annualSchedule(credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + const out = scheduleShell('ANNUAL', credits, ctx); + const ladder = buildLadder(credits, ctx); + const final = ladder[ladder.length - 1]; + let prevCumInterest = 0; + + for (const step of ladder) { + const isLast = step.quarter === 4; + out.lines.push({ + quarter: step.quarter, + label: QUARTER_LABEL[step.quarter], + dueDate: QUARTER_DUE_DATE[step.quarter], + interest: step.interest, + cumInterest: step.cumInterest, + cumLiableInterest: step.cumLiableInterest, + cumLiability: step.cumLiability, + tds: isLast ? final.cumLiability : 0, + cumTds: isLast ? final.cumLiability : 0, + thresholdCrossedHere: prevCumInterest <= out.threshold && step.cumInterest > out.threshold, + declarationActive: step.declarationActive + }); + prevCumInterest = step.cumInterest; + } + + out.totalTds = final.cumLiability; + return out; +} + +export function buildSchedule(mode: TdsMode, credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + return mode === 'QUARTERLY' ? quarterlySchedule(credits, ctx) : annualSchedule(credits, ctx); +} + +// ───────────────────────────── Reconciliation ────────────────────────────── + +export interface TdsReconciliation { + quarterlyTotal: number; + annualTotal: number; + /** Quarterly less annual. Positive means the quarterly book deducted more. */ + difference: number; + matched: boolean; + reason: string; +} + +/** + * Checks the two modes against each other for one customer. + * + * They agree to the rupee in every ordinary case, including a declaration taken + * mid-year: because a declaration operates prospectively, the year-end figure + * still recognises the interest credited before it was accepted as liable, and + * the annual book deducts on exactly that. Interest reversals agree too β€” the + * quarterly book adjusts them in-year. + * + * One case genuinely diverges, and it is not an arithmetic fault. If the + * year-to-date liability *falls* after a declaration has already frozen + * deductions β€” a large reversal dragging the aggregate back under the deduction + * trigger, say β€” the quarterly book is holding tax it may no longer collect but + * cannot refund either, while the annual book never deducts it at all. + * + * Worth running as a year-end control: a non-zero difference with no declaration + * on record, or with no fall in liability behind it, is a bug. + */ +export function reconcile(credits: InterestCredit[], ctx: TdsContext): TdsReconciliation { + const q = quarterlySchedule(credits, ctx); + const a = annualSchedule(credits, ctx); + const difference = q.totalTds - a.totalTds; + + let reason = 'Quarterly and annual settlement agree on the year.'; + if (difference !== 0) { + const firstActive = q.lines.find((l) => l.declarationActive); + reason = firstActive + ? `β‚Ή${difference.toLocaleString('en-IN')} was deducted before the declaration became operative in ` + + `${QUARTER_LABEL[firstActive.quarter]} and the year-to-date liability has since fallen. The branch ` + + `cannot refund it; the customer recovers it in their return.` + : `Unexplained difference of β‚Ή${difference.toLocaleString('en-IN')} β€” investigate before filing.`; + } + + return { quarterlyTotal: q.totalTds, annualTotal: a.totalTds, difference, matched: difference === 0, reason }; +} + +// ──────────────────────── Default interest (s.201(1A)) ────────────────────── + +/** + * Months, or parts of a month, between two dates. + * + * Section 201(1A) charges for "every month or part of a month", so any delay at + * all counts as a full month and a fortnight over a month counts as two. + */ +export function monthsOrPartBetween(fromIso: string, toIso: string): number { + if (!fromIso || !toIso || toIso <= fromIso) { + return 0; + } + const [ + y1, + m1, + d1 + ] = fromIso.split('-').map(Number); + const [ + y2, + m2, + d2 + ] = toIso.split('-').map(Number); + let months = (y2 - y1) * 12 + (m2 - m1); + if (d2 > d1) { + months += 1; + } + return Math.max(1, months); +} + +export interface LateInterestResult { + months: number; + rate: number; + interest: number; +} + +/** + * Interest payable by the bank for deducting late, or for depositing late. + * + * `kind` picks the limb: 'DEDUCTION' is 1% a month from the date the tax was + * deductible to the date it was actually deducted; 'DEPOSIT' is 1.5% a month + * from deduction to actual payment. This is the cost the branch carries if it + * settles outside the statutory cycle, and it is what makes deferring at a + * customer's request a bad trade for the bank. + */ +export function lateDeductionInterest( + amount: number, + dueDateIso: string, + actualDateIso: string, + kind: 'DEDUCTION' | 'DEPOSIT', + fy: string +): LateInterestResult { + const cfg = FY_TAX_CONFIG[normaliseFy(fy)]; + if (!cfg) { + throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`); + } + const rate = kind === 'DEDUCTION' ? cfg.lateDeductionRate : cfg.lateDepositRate; + const months = monthsOrPartBetween(dueDateIso, actualDateIso); + return { months, rate, interest: Math.round(amount * rate * months) }; +} + +// ──────────────────────────────── Projection ──────────────────────────────── + +/** + * Spreads a single annual interest estimate evenly across the four quarters. + * + * A convenience for screens that hold only a projected annual figure, as the + * Form 121 register does. The remainder lands in Q4 so the four parts add back + * to the estimate exactly rather than losing rupees to division. + */ +export function projectQuarterlyCredits(annualInterest: number, fy: string): InterestCredit[] { + const startYear = Number(normaliseFy(fy).slice(0, 4)); + const base = Math.floor(annualInterest / 4); + const dates = [ + `${startYear}-06-30`, + `${startYear}-09-30`, + `${startYear}-12-31`, + `${startYear + 1}-03-31` + ]; + return dates.map((date, i) => ({ date, amount: i === 3 ? annualInterest - base * 3 : base })); +} diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index bdcf03fb0..496f1e705 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -43,6 +43,18 @@ const routes: Routes = [ data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' }, component: SanctionsLogsComponent }, + { + 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', data: { routeParamBreadcrumb: 'filter' }, diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html new file mode 100644 index 000000000..9626f2a6e --- /dev/null +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -0,0 +1,325 @@ + + +
+
+
+ +
+

Deposit Interest & TDS

+
+ Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} Β· + {{ scopeLabel }} +
+
+
+ +
+ + +
+
+
{{ summary.deductees | number }}
+
Deductees
+
+
+
β‚Ή{{ summary.applicableInterest | number: '1.0-0' }}
+
Interest on FD / RD
+
+
+
β‚Ή{{ summary.exemptInterest | number: '1.0-0' }}
+
Savings interest β€” outside the charge
+
+
+
β‚Ή{{ summary.totalTds | number: '1.0-0' }}
+
TDS payable
+
+
+
{{ summary.crossed | number }}
+
Crossed the threshold
+
+ +
+
β‚Ή{{ summary.tdsSaved | number: '1.0-0' }}
+
Saved by {{ summary.declarations | number }} declarations
+
+ @if (summary.noPan > 0) { +
+
{{ summary.noPan | number }}
+
No PAN β€” deducted at 20%
+
+ } +
+ + +
+
Quarter by quarter
+
+ @for (q of summary.byQuarter; track q.quarter) { +
+
{{ quarterLabel[q.quarter] }}
+
β‚Ή{{ q.interest | number: '1.0-0' }}
+
TDS β‚Ή{{ q.tds | number: '1.0-0' }}
+
+ } +
+
+ + +
+ + + @if (canSelectBranch) { + + } + + + + + + + + + + + + @if (hasFilters) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + @for (row of rows; track trackRow($index, row)) { + + + + + @for (q of quarters; track q) { + + } + + + + + + + + + @if (isExpanded(row)) { + + + + } + } + @if (rows.length === 0) { + + + + } + +
Customer Β· PANBranchForm Β· BandQ1Q2Q3Q4Year totalThresholdCrossedTDSSaved
+
{{ row.deductee.name }}
+
+ {{ row.deductee.customerId }} Β· + @if (row.panOnRecord) { + {{ row.deductee.pan }} + } @else { + no PAN + } +
+
{{ branchName(row.branchCode) }} + + {{ row.form === '121' ? '121' : row.form }} + +
{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}
+ @if (row.hasDeclaration) { +
{{ row.declarationUin }}
+ } +
+ @if (row.quarters[q]) { + β‚Ή{{ row.quarters[q] | number: '1.0-0' }} + } @else { + β€” + } + + β‚Ή{{ row.tdsApplicableInterest | number: '1.0-0' }} + @if (row.exemptInterest) { +
+β‚Ή{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)
+ } +
β‚Ή{{ row.threshold | number: '1.0-0' }} + @if (row.crossedInQuarter) { + Q{{ row.crossedInQuarter }} + } @else if (row.nearThreshold) { + β‚Ή{{ row.headroom | number: '1.0-0' }} left + } @else { + β€” + } + + @if (row.schedule.totalTds) { + β‚Ή{{ row.schedule.totalTds | number: '1.0-0' }} +
@ {{ row.rateApplied * 100 | number: '1.0-0' }}%
+ } @else { + β€” + } +
+ @if (row.tdsSaved) { + β‚Ή{{ row.tdsSaved | number: '1.0-0' }} + } @else { + β€” + } + + +
+ @for (note of row.schedule.notes; track note) { +
+ +
{{ note }}
+
+ } + @if (row.jointHeldInterest) { +
+ +
+ β‚Ή{{ 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. +
+
+ } + + + + + + + + + + + + + + @for (line of row.schedule.lines; track line.quarter) { + + + + + + + + + } + +
QuarterInterestCumulativeLiability to dateTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + crossed here + } + @if (line.declarationActive) { + Form 121 active + } + β‚Ή{{ line.interest | number: '1.0-0' }}β‚Ή{{ line.cumInterest | number: '1.0-0' }}β‚Ή{{ line.cumLiability | number: '1.0-0' }} + @if (line.tds !== 0) { + β‚Ή{{ line.tds | number: '1.0-0' }} + } @else { + β€” + } + {{ line.dueDate }}
+ +
+ Settlement: {{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }} (set by + the deposit product) + @if (row.declarationAcceptedOn) { + Declaration accepted: {{ row.declarationAcceptedOn | date: 'd MMM y' }} + } + @if (row.paidWithoutDeduction) { + + Paid without deduction: + β‚Ή{{ row.paidWithoutDeduction | number: '1.0-0' }} β€” reportable in + {{ statute.statement }} + + } +
+
+ No deductees match the current filters. +
+
+
+
diff --git a/src/app/tds-reports/tds-interest-report.component.ts b/src/app/tds-reports/tds-interest-report.component.ts new file mode 100644 index 000000000..5fecd298a --- /dev/null +++ b/src/app/tds-reports/tds-interest-report.component.ts @@ -0,0 +1,250 @@ +/** + * 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 { + 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; + } + } +} diff --git a/src/app/tds-reports/tds-remittance-report.component.html b/src/app/tds-reports/tds-remittance-report.component.html new file mode 100644 index 000000000..4364cd3f9 --- /dev/null +++ b/src/app/tds-reports/tds-remittance-report.component.html @@ -0,0 +1,237 @@ + + +
+
+
+ +
+

TDS Remitted to Government

+
+ Challan-wise deposits and the {{ statute.statement }} year-end position Β· {{ scopeLabel }} Β· as at + {{ asAt | date: 'd MMM y' }} +
+
+
+
+ + +
+
+ + + @if (reconciled) { +
+ +
+ Reconciled. + β‚Ή{{ 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. +
+
+ } @else { +
+ +
+ Does not reconcile β€” β‚Ή{{ summary.difference | number: '1.0-0' }} unaccounted. + Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the + return. +
+
+ } + + @if (summary.overdueChallans > 0) { +
+ +
+ {{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue. + Interest runs at 1.5% for every month or part of a month until the tax reaches the Government. +
+
+ } + + +
+
+
β‚Ή{{ summary.totalDeducted | number: '1.0-0' }}
+
Total TDS deducted
+
+
+
β‚Ή{{ summary.totalDeposited | number: '1.0-0' }}
+
Deposited with Government
+
+
+
β‚Ή{{ summary.pendingDeposit | number: '1.0-0' }}
+
Within due date
+
+
+
β‚Ή{{ totalDefaultInterest | number: '1.0-0' }}
+
Interest on late deposit
+
+
+
{{ summary.deductees | number }}
+
Deductees in the return
+
+
+ + +
+
Part A β€” challan-wise remittance
+
+ + + + + + + + + + + + + + + + @for (row of rows; track trackRow($index, row)) { + + + + + + + + + + + + } + @if (rows.length === 0) { + + + + } + +
QuarterDeduction monthAmountDue dateDeposited onChallan Β· BSRStatusLate-deposit interest{{ statute.statement }} due
+ {{ quarterLabel[row.quarter] }} + {{ row.month }} + β‚Ή{{ row.amount | number: '1.0-0' }} + {{ row.dueDate | date: 'd MMM y' }} + @if (row.depositedOn) { + {{ row.depositedOn | date: 'd MMM y' }} + } @else { + β€” + } + + @if (row.challanNo) { + {{ row.challanNo }} +
BSR {{ row.bsrCode }}
+ } @else { + β€” + } +
+ @if (row.overdue) { + Overdue + } @else if (row.late) { + Late by {{ row.delayMonths }}m + } @else if (row.depositedOn) { + On time + } @else { + Within due date + } + + @if (row.defaultInterest) { + β‚Ή{{ row.defaultInterest | number: '1.0-0' }} +
1.5% Γ— {{ row.delayMonths }}
+ } @else { + β€” + } +
{{ row.returnDueDate }}
+ No tax was deducted in this financial year. +
+
+
+ Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April. +
+
+ + +
+
+ Part B β€” {{ statute.statement }} year-end position +
+
+
+ Total interest paid on time deposits + β‚Ή{{ summary.totalInterestPaid | number: '1.0-0' }} +
+
+ with tax deducted + β‚Ή{{ summary.interestWithDeduction | number: '1.0-0' }} +
+
+ + without deduction β€” declaration on record + mandatory disclosure in {{ statute.statement }} + + β‚Ή{{ summary.interestWithoutDeduction | number: '1.0-0' }} +
+ +
+ TDS at standard rate β€” PAN on record + β‚Ή{{ summary.tdsAtStandardRate | number: '1.0-0' }} +
+
+ TDS at higher rate β€” no PAN on record + β‚Ή{{ summary.tdsAtHigherRate | number: '1.0-0' }} +
+ +
+ Total TDS deducted + β‚Ή{{ summary.totalDeducted | number: '1.0-0' }} +
+
+ Deposited with Government + β‚Ή{{ summary.totalDeposited | number: '1.0-0' }} +
+
+ Still within due date + β‚Ή{{ summary.pendingDeposit | number: '1.0-0' }} +
+
+ Difference β€” must be zero + β‚Ή{{ summary.difference | number: '1.0-0' }} +
+ + @if (summary.defaultInterest > 0) { +
+ Interest payable by the bank for late deposit + β‚Ή{{ summary.defaultInterest | number: '1.0-0' }} +
+ } +
+
+ 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. +
+
+
diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts new file mode 100644 index 000000000..5a1b9ce66 --- /dev/null +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -0,0 +1,203 @@ +/** + * 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 { + 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; + } + } +} diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts new file mode 100644 index 000000000..6cffaa08f --- /dev/null +++ b/src/app/tds-reports/tds-reports-data.ts @@ -0,0 +1,396 @@ +/** + * 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; + 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(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 = {}; + 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 | 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>): Record { + if (challanCache) { + return challanCache; + } + const out: Record = {}; + + for (const fy of TDS_FYS) { + const y = Number(fy.slice(0, 4)); + const months: Record = { + 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; +} diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts new file mode 100644 index 000000000..b128afe9c --- /dev/null +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -0,0 +1,301 @@ +/** + * 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 { + 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); + } + }); +}); diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts new file mode 100644 index 000000000..3fb6f3ed7 --- /dev/null +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -0,0 +1,395 @@ +/** + * 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; + /** 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 { + 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 = { + 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 { + const out: Record = { 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> = {}; + 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 + }; +} diff --git a/src/app/tds-reports/tds-reports.shared.scss b/src/app/tds-reports/tds-reports.shared.scss new file mode 100644 index 000000000..a40222577 --- /dev/null +++ b/src/app/tds-reports/tds-reports.shared.scss @@ -0,0 +1,546 @@ +/** + * 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); + } +} diff --git a/src/app/tds-reports/tds-statutory.compute.ts b/src/app/tds-reports/tds-statutory.compute.ts new file mode 100644 index 000000000..decb83d85 --- /dev/null +++ b/src/app/tds-reports/tds-statutory.compute.ts @@ -0,0 +1,466 @@ +/** + * 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(); + 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 { + const out = new Map(); + 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 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)); +} From 2cc8d979ba52177be45d3b9417831c8f545c8540 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 5 Aug 2026 18:32:48 +0530 Subject: [PATCH 5/6] fix(shares): fix popup fixed-position bug, add Client Compliance card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backdrop-filter on the list-embedded review popup was making it a new containing block for its own nested position:fixed dialogs (Validate, Reject, Confirm-Submit), confining them to the popup's box instead of the viewport β€” replaced the blur with a plain darker overlay. Also adds a permanent Client Compliance card (KYC status, risk category, open AML alerts) on the review screen so a manager can see it without opening the Validate popup. Co-Authored-By: Claude Sonnet 5 --- .../share-withdrawal-request.component.html | 38 +++++++++++++++++++ .../share-withdrawal-request.component.scss | 27 ++++++++++++- .../share-withdrawal-requests.component.scss | 8 +++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html index 58810efaf..f8c536f33 100644 --- a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html @@ -63,6 +63,44 @@ + @if (isReview && reviewRequest) { +
+ +
+
+ KYC Status + + {{ reviewRequest.client_kyc_status_label }} + +
+
+ Risk Category + + {{ reviewRequest.client_risk_category_label }} + +
+
+ Open AML Alerts + + {{ reviewRequest.client_open_aml_alerts || 0 }} + +
+
+
+ } + @if (account) {
diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss index 619822696..140ccec89 100644 --- a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss @@ -58,6 +58,26 @@ 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; @@ -138,8 +158,11 @@ .swrv-modal { position: fixed; inset: 0; - background: rgb(15 25 45 / 55%); - backdrop-filter: blur(3px); + // 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; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss index 93afc827a..e3b249c01 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss @@ -31,8 +31,12 @@ .swr-list-popup-backdrop { position: fixed; inset: 0; - background: rgb(15 25 45 / 55%); - backdrop-filter: blur(3px); + // 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; From 983c613b8e2d5927830793f3e9b6c7d0050d2c95 Mon Sep 17 00:00:00 2001 From: Anshul Rathore Date: Thu, 6 Aug 2026 10:41:28 +0530 Subject: [PATCH 6/6] feat: update changes --- angular.json | 4 +- ...-family-member-child-dialog.component.html | 90 +++++++ ...nt-family-member-child-dialog.component.ts | 98 ++++++++ ...client-family-member-dialog.component.html | 28 +++ .../client-family-member-dialog.component.ts | 117 +++++++++- .../client-kyc-step.component.ts | 15 +- .../welcome-kit-dialog.component.html | 29 ++- .../welcome-kit-dialog.component.ts | 24 ++ src/app/clients/clients.module.ts | 2 + src/app/clients/clients.service.ts | 18 ++ .../edit-client/edit-client.component.html | 46 +++- .../edit-client/edit-client.component.ts | 131 ++++++++--- .../share-holders.component.html | 10 +- .../share-holders.component.scss | 204 +++++++++++----- .../client-edit-email-dialog.component.html | 84 +++++++ .../client-edit-email-dialog.component.scss | 219 ++++++++++++++++++ .../client-edit-email-dialog.component.ts | 65 ++++++ .../pending-client-edits.component.html | 25 ++ .../pending-client-edits.component.scss | 38 +++ .../pending-client-edits.component.ts | 43 +++- .../users/dsc/dsc-list/dsc-list.component.ts | 4 +- 21 files changed, 1189 insertions(+), 105 deletions(-) create mode 100644 src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html create mode 100644 src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts diff --git a/angular.json b/angular.json index 1fe5293cf..fd43e003d 100644 --- a/angular.json +++ b/angular.json @@ -69,8 +69,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "13mb", - "maximumError": "14mb" + "maximumWarning": "15mb", + "maximumError": "16mb" }, { "type": "anyComponentStyle", diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html new file mode 100644 index 000000000..a681b04e5 --- /dev/null +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html @@ -0,0 +1,90 @@ +
+
+ people + CHILDREN +
+ + +
+
+ @for (child of children.controls; track $index) { +
+
+ + Child Full Name* + + + + Gender* + + @for (g of genderIdOptions; track g.id) { + {{ g.name }} + } + + + + Date of Birth + + + + + + Nationality + + Indian + NRI + Foreign National + Other + + + + Aadhaar Number (Optional) + + + + PAN Number (Optional) + + @if ( + $any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched + ) { + Invalid PAN format + } + +
+
+ @if ($index < children.controls.length - 1) {
+
+ } + } +
+ +
+
+
+ + + + + +
\ No newline at end of file diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts new file mode 100644 index 000000000..6e457afbb --- /dev/null +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts @@ -0,0 +1,98 @@ +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, + @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); + } + } + +} diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html index d5b1e8492..6b3c5ea75 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html @@ -139,6 +139,21 @@ {{ familyMemberForm.get('remarks')?.value }}
} + @if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) { +
+ Children +
+ @for (child of familyMemberForm.get('children')?.value; track $index) { +
+ {{ child.fullName }} + @if (child.dateOfBirth) { + Β· DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }} + } +
+ } +
+
+ } } @@ -447,6 +462,19 @@ Remarks + + + @if (isSpouse) { + +
+ Does this family member have a child/children? + @if (familyMemberForm.get('hasChildren')?.value) { + + } +
+ } } diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index 552c39daf..49930b556 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -17,7 +17,7 @@ import { MatDialogClose, MatDialogContent } from '@angular/material/dialog'; -import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { ClientsService } from 'app/clients/clients.service'; @@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit { private dialog = inject(MatDialog); maxDate = new Date(); + + hasChildrenChecked = false; familyMemberForm: UntypedFormGroup; /** Client search */ @@ -197,6 +199,23 @@ export class ClientFamilyMemberDialogComponent implements OnInit { // Re-fetch linked client details (profile, signature, KYC status) 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) { this.familyMemberForm.disable(); } @@ -225,8 +244,83 @@ export class ClientFamilyMemberDialogComponent implements OnInit { isAlive: [true], marriageDate: [''], 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 */ @@ -481,6 +575,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit { 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 if (this.marriageCertFile) { member._marriageCertFile = this.marriageCertFile; diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 3ebaea616..c062d834f 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -144,7 +144,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { kycDocumentName: 'KYC Document Type', kycDocumentNumber: 'KYC Document Number', 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 @@ -415,8 +416,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { aadhaarNumber: [ '', [ - Validators.pattern(/^\d{12}$/), - Validators.maxLength(12) + Validators.pattern(/^(\d{12}|[Xβ€’*\s\d]{12,14})$/), + Validators.maxLength(14) ] ], panAadhaarSeedingStatus: [''], @@ -890,6 +891,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form15Type: data.form15Type || '15G', form15FY: data.form15FY || '' }); + + // 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 + } + // Sync chip-picker selection with the prefilled document name if (data.kycDocumentName) { this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null; diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html index d7f979973..2ad771dcb 100644 --- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html +++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.html @@ -36,7 +36,34 @@ Status: {{ dispatch.status }}

- + + + +
+ + +
+
+
diff --git a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts index b3d72602b..0ef5ca2d5 100644 --- a/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component.ts @@ -165,6 +165,30 @@ 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 { const address = dispatch.deliveryAddress || ''; const name = this.welcomeKitsData?.clientName || ''; diff --git a/src/app/clients/clients.module.ts b/src/app/clients/clients.module.ts index 5171c0131..bd13c7eca 100644 --- a/src/app/clients/clients.module.ts +++ b/src/app/clients/clients.module.ts @@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family 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 { 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 { 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'; @@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone ClientPreviewStepComponent, ClientAddressStepComponent, ClientFamilyMemberDialogComponent, + ClientFamilyMemberChildDialogComponent, CaptureImageDialogComponent, AddClientCollateralComponent, ClientDatatableStepComponent, diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 14a2efc49..619316799 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -123,6 +123,20 @@ export class ClientsService { 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) { const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true'); return this.http.get(`/clients/${clientId}`, { params: httpParams }); @@ -958,4 +972,8 @@ export class ClientsService { dispatchWelcomeKit(clientId: string | number, data: any): Observable { return this.http.post(`/clients/${clientId}/welcomekit`, data); } + + updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable { + return this.http.put(`/welcomekit/${welcomeKitId}`, { status }); + } } diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 92740d87c..748eba1fa 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -84,14 +84,46 @@ phone_iphone Mobile Number +
+ + Old mobile number not accessible (lost / deactivated SIM) + +
- + @if (!oldMobileNotAccessible) { + + } @else { +
+
+ Upload Police Complaint / FIR Copy for Lost Mobile +
+
+ + + @if (uploadingFir) { + + } + @if (firDocumentId && !uploadingFir) { + + check_circle FIR Uploaded Successfully + + } +
+
+ } { + 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 { this.oldMobileOtpId = state.otpId; this.oldMobileVerified = state.verified; @@ -254,6 +303,10 @@ export class EditClientComponent { this.newMobileOtpId = null; this.oldMobileVerified = false; this.newMobileVerified = false; + this.oldMobileNotAccessible = false; + this.firFileName = ''; + this.firDocumentId = null; + this.uploadingFir = false; this.oldEmailOtpId = null; this.newEmailOtpId = null; this.oldEmailVerified = false; @@ -439,7 +492,11 @@ export class EditClientComponent { // email's when BOTH changed in the same save. See the class-level JSDoc above the OTP // state fields for the full naming rationale. if (changedFields.mobileChanged) { - clientData.mobileOldOtpId = this.oldMobileOtpId; + if (this.oldMobileNotAccessible && this.firDocumentId) { + (clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId; + } else { + clientData.mobileOldOtpId = this.oldMobileOtpId; + } clientData.mobileNewOtpId = this.newMobileOtpId; } if (changedFields.emailChanged) { @@ -630,25 +687,34 @@ export class EditClientComponent { private detectSensitiveFieldChanges(originalData: any) { const form = this.clientGeneralForm; - // Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as - // a "change" β€” the masked preview shown in the Verify Contact Change panel only reveals the - // first couple of characters, so a whitespace-only difference is otherwise invisible to the - // user and just looks like the panel appeared for no reason. These trimmed-but-original-case - // values are what get returned/sent as the actual OTP destination and saved value β€” only the - // *comparison* (below) is further case-normalized for email, so we never silently rewrite the - // customer's email casing into the saved record or the OTP destination. - const newMobile = (form?.get('mobileNo')?.value || '').trim(); - const newEmail = (form?.get('emailAddress')?.value || '').trim(); - const newDobRaw = form?.get('dateOfBirth')?.value; - // Local calendar date, NOT `.toISOString()` β€” that converts to UTC first, which silently - // shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30: - // midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison - // 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. + if (!form || !originalData) { + return { + nameChanged: false, + mobileChanged: false, + emailChanged: false, + dobChanged: false, + oldName: '', + newName: '', + oldMobile: '', + newMobile: '', + oldEmail: '', + newEmail: '', + oldDob: '', + newDob: '' + }; + } + + 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 oldMobile = (originalData.mobileNo || '').trim(); - const oldEmail = (originalData.emailAddress || '').trim(); + const oldMobile = (originalData.mobileNo || '').toString().trim(); + const oldEmail = (originalData.emailAddress || '').toString().trim(); const oldDobRaw = originalData.dateOfBirth; const oldDob = Array.isArray(oldDobRaw) ? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}` @@ -669,10 +735,10 @@ export class EditClientComponent { ).trim(); const newName = buildName({ - firstname: form?.get('firstname')?.value || '', - middlename: form?.get('middlename')?.value || '', - lastname: form?.get('lastname')?.value || '', - fullname: form?.get('fullname')?.value || '' + firstname: form.get('firstname')?.value || '', + middlename: form.get('middlename')?.value || '', + lastname: form.get('lastname')?.value || '', + fullname: form.get('fullname')?.value || '' }); const oldName = buildName({ firstname: originalData.firstname || '', @@ -681,12 +747,19 @@ export class EditClientComponent { 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 { nameChanged: newName !== oldName, - mobileChanged: newMobile !== oldMobile, - // 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(), + mobileChanged, + emailChanged, dobChanged: newDob !== oldDob, oldName, newName, diff --git a/src/app/shares/share-holders/share-holders.component.html b/src/app/shares/share-holders/share-holders.component.html index daae3fe22..15adb5d11 100644 --- a/src/app/shares/share-holders/share-holders.component.html +++ b/src/app/shares/share-holders/share-holders.component.html @@ -20,11 +20,11 @@
- + - + All Branches @@ -33,7 +33,7 @@ - + All Products @@ -42,7 +42,7 @@ - + All Status Active @@ -53,7 +53,7 @@ - + diff --git a/src/app/shares/share-holders/share-holders.component.scss b/src/app/shares/share-holders/share-holders.component.scss index 6d41b20a0..27c01a151 100644 --- a/src/app/shares/share-holders/share-holders.component.scss +++ b/src/app/shares/share-holders/share-holders.component.scss @@ -5,37 +5,43 @@ * 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; + width: 100%; +} + .container { - margin: 1.5rem; - padding: 0; - border-radius: var(--radius-xl); - background: var(--bg-card); - box-shadow: var(--shadow-sm); + margin: 1.5rem auto; + max-width: 1400px; + padding: 20px 24px; + border-radius: 16px; + background: #ffffff; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + box-sizing: border-box; } .table-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border-light); + margin-bottom: 20px; .table-header-row { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; - flex-wrap: wrap; - gap: 1rem; + margin-bottom: 16px; } .table-title-group { display: flex; align-items: center; - gap: 1rem; + gap: 12px; .table-title { margin: 0; font-size: 1.25rem; font-weight: 600; - color: var(--text-main); + color: #0f172a; display: flex; align-items: center; gap: 0.75rem; @@ -45,83 +51,175 @@ width: 24px; height: 24px; position: relative; - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: 1fr 1fr; gap: 2px; padding: 2px; - background: var(--bg-hover); + background: #f1f5f9; border-radius: 6px; span { - width: 8px; - height: 8px; border-radius: 2px; - background: var(--primary-main); - opacity: 0.8; + background: #1353a0; - &:nth-child(2) { - opacity: 0.6; - } - - &:nth-child(3) { - opacity: 0.4; - } - - &:nth-child(4) { - opacity: 0.2; - } + &:nth-child(2) { opacity: 0.7; } + &:nth-child(3) { opacity: 0.4; } + &:nth-child(4) { opacity: 0.2; } } } } .table-count { - padding: 0.25rem 0.75rem; - background: var(--bg-hover); - color: var(--text-muted); - border-radius: var(--radius-full); + padding: 0.2rem 0.65rem; + background: #f1f5f9; + color: #64748b; + border-radius: 12px; font-size: 0.75rem; font-weight: 500; - letter-spacing: 0.02em; + font-family: 'Roboto Mono', monospace; } } + /* Pill-styled filter bar */ .table-filters { display: flex; - gap: 1rem; align-items: center; + gap: 10px; flex-wrap: wrap; + padding: 8px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; - mat-form-field { - width: 240px; - margin-bottom: -1.25em; + .table-search { + flex: 1 1 200px; + min-width: 180px; + 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 { - position: relative; - overflow: auto; - border-radius: 0 0 var(--radius-xl) var(--radius-xl); + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + background: #ffffff; .table-loader { - position: absolute; - top: 0; - left: 0; - right: 0; - z-index: 10; + height: 2px; } } .modern-table { width: 100%; - background: transparent; + border-collapse: separate; + border-spacing: 0; - tr.mat-row { - cursor: pointer; - transition: background-color 0.2s ease; + ::ng-deep { + .mat-mdc-header-row { + background: #f8fafc; + min-height: 38px; + height: 38px; + } - &:hover { - background-color: var(--bg-hover); + .mat-mdc-header-cell { + font-size: 11px; + 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; +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html new file mode 100644 index 000000000..a4b311eee --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html @@ -0,0 +1,84 @@ + + +

+ βœ‰ + Approval Email + +
+ @if (data.emailSent) { + + Sent + + } @else { + + Not sent + + } + + @if (data.emailOpenedAt) { + + Seen + + } @else { + + Not yet seen + + } +
+

+ +
+ + +
+

{{ data.emailSubject ?? '(No Subject)' }}

+ +
+
+
C
+
+ +
+
+ CBSNexora + <{{ data.emailFrom ?? 'noreply@cbsnexora.in' }}> + + {{ data.emailSentAt ? formatDate(data.emailSentAt) : 'β€”' }} +
+
+ + to + @if (data.emailTo) { + {{ data.emailTo }} + } @else { + No email on file + } + + +
+
+
+
+ + + @if (data.emailBody) { +
+ } @else { +
+

No email content available.

+ @if (!data.emailSent) { +

The email has not been sent yet for this edit.

+ } +
+ } +
+ + + + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss new file mode 100644 index 000000000..f50f1cd82 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss @@ -0,0 +1,219 @@ +/** + * Copyright since 2025 Mifos Initiative + * 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 0; + 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 #eeeeee; + 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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts new file mode 100644 index 000000000..e2a18cb2d --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts @@ -0,0 +1,65 @@ +/** + * 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(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 + }); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html index 98f557aed..19470635b 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.html @@ -126,6 +126,25 @@ Actions + + + + {{ edit.emailSent ? 'Sent' : 'Not Sent' }} + + @if (edit.emailSent) { + + + {{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }} + + } + + @@ -137,6 +156,12 @@ Reject } + @if (edit.emailSent) { + + } diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss index 61ffa8119..22992daa7 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.scss @@ -346,3 +346,41 @@ $text-muted: #64748b; 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; + } +} + diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts index c3fdc1c3b..a9dbab2fa 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component.ts @@ -34,6 +34,7 @@ import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; 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 { 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 * resolved against β€” populated from /clients/template plus the same per-code-value lookups the @@ -97,7 +98,8 @@ const NATIONALITY_LABELS: Record = { MatRowDef, MatRow, MatIcon, - MatPaginatorModule + MatPaginatorModule, + ClientEditEmailDialogComponent ] }) export class PendingClientEditsComponent implements OnInit { @@ -423,6 +425,45 @@ 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 { this.load(); } diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.ts b/src/app/users/dsc/dsc-list/dsc-list.component.ts index 429c65c72..aa2fb82e3 100644 --- a/src/app/users/dsc/dsc-list/dsc-list.component.ts +++ b/src/app/users/dsc/dsc-list/dsc-list.component.ts @@ -21,7 +21,7 @@ import { MatRow } from '@angular/material/table'; import { MatButton, MatIconButton } from '@angular/material/button'; -import { MatIcon } from '@angular/material/icon'; +import { MatIconModule } from '@angular/material/icon'; import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; import { MatTooltip } from '@angular/material/tooltip'; import { MatProgressBar } from '@angular/material/progress-bar'; @@ -52,7 +52,7 @@ import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog MatRow, MatButton, MatIconButton, - MatIcon, + MatIconModule, MatMenu, MatMenuItem, MatMenuTrigger,