diff --git a/angular.json b/angular.json index fd43e003d..341741725 100644 --- a/angular.json +++ b/angular.json @@ -70,7 +70,7 @@ { "type": "initial", "maximumWarning": "15mb", - "maximumError": "16mb" + "maximumError": "17mb" }, { "type": "anyComponentStyle", 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 60cb80889..78839ffff 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,11 +327,8 @@ 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?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not mark this entry.'; + err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.'; } }); } diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index e6fafaf2d..fcfb63214 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -62,22 +62,6 @@ 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 deleted file mode 100644 index fde90ad02..000000000 --- a/src/app/cashier/clerk-share-withdrawal.service.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; - -/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow β€” mirrors - * ClerkWithdrawalService (cash withdrawal), adapted for shares. */ -@Injectable({ providedIn: 'root' }) -export class ClerkShareWithdrawalService { - private http = inject(HttpClient); - - submit(data: any): Observable { - 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 0b62d5029..9275e0c3e 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 paymentTypesForRow(row); track pt.id) { + @for (pt of paymentTypes; track pt.id) { } @@ -231,11 +231,8 @@ 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.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') { -
- - -
-
- - -
- } @else if (row.typ !== 'Withdrawal Slip') { + @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 }} -
- } - }
@@ -781,13 +724,7 @@
1. - {{ - selectedRow?.acTyp === 'SHR' - ? 'Share Balance' - : accountDetails?.glCode - ? 'GL Balance' - : 'Current Balance' - }} + {{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }} {{ currencySymbol }}{{ currentBalance | number: '1.2-2' }} @@ -1344,7 +1281,7 @@ [class.ws-f__ctl--req]="!row.paymentTypeId" > - @for (pt of paymentTypesForRow(row); track pt.id) { + @for (pt of paymentTypes; track pt.id) { } @@ -1423,7 +1360,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; @@ -1240,9 +1205,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }; this.rows.push(mirror); this.selectedRow = mirror; - if (active.acTyp === 'SHR') { - this.syncShareLinkedAmounts(); - } return; } const row: TxnRow = { @@ -1537,182 +1499,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } - /** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its - * "SHARE NO." field), CR row must be that account's own linked savings account. Submits a - * governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) β€” - * approval both redeems the shares and settles the linked account automatically. */ - private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void { - // Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings) - // row (see paymentTypesForRow()), but re-check here too in case its payment type was set before - // that filter applied β€” settlement is always a book-transfer to the linked savings account, and - // it's the CR row's payment type that actually governs how that credit posts, never the DR - // (Share) row's, which has no real payment-type concept. - const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId); - if (!savingsRow.paymentTypeId) { - this.showAlert('error', 'Settlement Payment Type Required', [ - 'Select a Payment Type on the CR (savings account) row β€” it governs how the settlement credit posts.' - ]); - return; - } - if (settlementPaymentType?.isCashPayment) { - this.showAlert('error', 'Cash Not Allowed for Share Transactions', [ - 'Share redemption settlement is always a book-transfer to the linked savings account, never cash.', - 'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).' - ]); - return; - } - - const shareAccountId = this.rowAccountIds[shareRow.acNo]; - if (!shareAccountId) { - this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]); - return; - } - if (!this.rowAccountIds[savingsRow.acNo]) { - this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]); - return; - } - - const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo]; - const enteredAcNo = savingsRow.acNo?.trim(); - const matchesLinked = - !!linkedAcNo && - (linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo); - if (linkedAcNo && !matchesLinked) { - this.showAlert('error', 'Wrong Credit Account', [ - `The CR account must be this Share account's own linked savings account: ${linkedAcNo}`, - `You entered: ${enteredAcNo}` - ]); - return; - } - if (!linkedAcNo) { - this.showAlert('error', 'No Linked Savings Account', [ - 'This Share account has no linked savings account configured β€” settlement has nowhere to go.' - ]); - return; - } - - const shares = Number(shareRow.amount) || 0; - if (shares <= 0) { - this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']); - return; - } - if (!shareRow.resolutionNumber?.trim()) { - this.showAlert('error', 'Board Resolution Number Required', [ - 'A share withdrawal must be authorised by a Board Resolution.' - ]); - return; - } - if (!shareRow.meetingDate) { - this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']); - return; - } - - const today = new Date(); - const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; - - // Full snapshot of the CR (settlement) row exactly as the clerk filled it in β€” saved verbatim - // for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of - // these fields are not re-read by the backend, they are the audit record of what was on screen. - const settlementRowSnapshot = { - branch: savingsRow.br, - accountType: savingsRow.acTyp, - accountNo: savingsRow.acNo, - ecsAccountNo: savingsRow.ecsAcNo || null, - dbCr: savingsRow.dbCr, - amount: savingsRow.amount ?? null, - chequeNo: savingsRow.chequeNo || null, - chequeDate: savingsRow.cheqDate || null, - tokenNo: savingsRow.tokNo || null, - description: savingsRow.description || null, - bankName: savingsRow.bankName || null, - routingCode: savingsRow.routingCode || null, - paymentTypeId: savingsRow.paymentTypeId, - paymentTypeName: settlementPaymentType?.name ?? null - }; - - const payload = { - shareAccountId, - accountNo: shareRow.acNo, - clientId: this.rowClientIds[shareRow.acNo] ?? null, - clientName: this.rowAccountNames[shareRow.acNo] ?? null, - requestedShares: shares, - requestedDate, - resolutionNumber: shareRow.resolutionNumber.trim(), - meetingDate: shareRow.meetingDate, - description: shareRow.description || null, - settlementPaymentTypeId: savingsRow.paymentTypeId, - settlementRowSnapshot - }; - - const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0; - const confirmLines = [ - `Share Account: ${shareRow.acNo} Β· Client: ${this.rowAccountNames[shareRow.acNo] ?? 'β€”'}`, - `Shares to Withdraw: ${shares} @ β‚Ή${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = β‚Ή${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, - `Board Resolution No.: ${payload.resolutionNumber}`, - `Date of Meeting: ${payload.meetingDate}`, - `Settlement Credit To: ${linkedAcNo}`, - `Settlement Payment Type: ${settlementPaymentType?.name ?? 'β€”'}`, - ...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []), - ...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []), - ...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []), - ...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []), - ...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []), - '', - 'Validation checks:', - ` βœ“ Share account and linked savings account both loaded`, - ` βœ“ CR account matches this Share account's linked savings account`, - ` βœ“ Settlement Payment Type is non-cash`, - ` βœ“ Board Resolution Number and Date of Meeting provided`, - '', - 'Pending Manager approval β€” redemption and settlement both post together on approval.' - ]; - this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () => - this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo) - ); - } - - private doSubmitShareRedemptionTransfer( - payload: Record, - 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; @@ -1749,25 +1535,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { }); } - /** When exactly one row on screen is a Share account with a known market price, auto-fills every - * OTHER row's amount as (shares entered on that row) Γ— (its market price) β€” a calculator - * convenience for filling the paired settlement entry. The Share row itself still can never be - * saved from this screen (see the SHR guard in confirmSubmit()) β€” this only helps compute the - * correct amount to carry over into the separate manual Deposit Slip entry. */ - syncShareLinkedAmounts(): void { - const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]); - if (shareRows.length !== 1) return; - const shareRow = shareRows[0]; - const price = this.rowSharePrices[shareRow.acNo] ?? 0; - const shares = Number(shareRow.amount) || 0; - const total = Math.round(shares * price * 100) / 100; - this.rows.forEach((r) => { - if (r !== shareRow) { - r.amount = total; - } - }); - } - onAcNoBlur(row: TxnRow, index: number): void { if (!row.acNo.trim()) return; this.selectedRow = row; @@ -1866,14 +1633,12 @@ 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. 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. + // Exact match first, then suffix/partial match (backend may return by last-N-digits) const exactMatch = allItems.find((x: any) => x[f] === acNo); - const partialMatch = !exactMatch - ? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo))) - : null; + const partialMatch = + !exactMatch && allItems.length === 1 + ? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo)) + : null; const match = exactMatch ?? partialMatch ?? null; if (match?.id) { const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; @@ -1894,17 +1659,6 @@ 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; @@ -1939,8 +1693,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: 4, txnBase: '/accounts/share', txnSearchSupported: false, - txnFetchUnsupported: true, - siFetchUnsupported: true, maxLimit: 500, skipGlobalSearch: true, listQueryParam: 'accountNo', @@ -2053,8 +1805,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { siAccountType: number; txnBase: string; txnSearchSupported: boolean; - txnFetchUnsupported?: boolean; - siFetchUnsupported?: boolean; detailParams?: string; }, token: number, @@ -2087,29 +1837,13 @@ 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) { - // 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 ?? ''); + this.selectedRow.ecsAcNo = 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; }; @@ -2179,22 +1913,18 @@ 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: cfg.siFetchUnsupported - ? of({ pageItems: [] }) + 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: [] }))) : this.http - .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: [] }))) + .get(`${cfg.txnBase}/${accountId}/transactions?limit=50`) + .pipe(catchError(() => of({ pageItems: [] }))) }); if (clientIdHint) { @@ -2359,11 +2089,6 @@ 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; } @@ -2777,23 +2502,6 @@ 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', @@ -3296,24 +3004,6 @@ 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 deleted file mode 100644 index 0695f3600..000000000 --- a/src/app/clerk/clerk-shared.scss +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens β€” - * self-contained (this branch has no separate "investment" module to borrow them from). */ - -.inv-form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; -} - -.inv-form-field { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 11px; - font-weight: 600; - color: #64748b; - - input, - select, - textarea { - height: 36px; - padding: 0 10px; - border: 1px solid #e2e8f0; - border-radius: 8px; - font-size: 13px; - color: #0d1b2a; - font-family: inherit; - background: #fff; - box-sizing: border-box; - } - - textarea { - height: auto; - padding: 8px 10px; - resize: vertical; - } -} - -.inv-form-actions { - display: flex; - gap: 10px; - padding: 12px 16px 16px; -} - -.inv-badge { - display: inline-flex; - align-items: center; - padding: 3px 10px; - border-radius: 999px; - font-size: 11px; - font-weight: 700; - background: #f1f5f9; - color: #475569; - - &--active { - background: #fef3c7; - color: #92400e; - } - - &--matured { - background: #dcfce7; - color: #166534; - } - - &--written_off { - background: #fee2e2; - color: #991b1b; - } -} 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 deleted file mode 100644 index 9d57ea8f4..000000000 --- a/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html +++ /dev/null @@ -1,73 +0,0 @@ - - -
-
-
-
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 deleted file mode 100644 index 3d19c8b57..000000000 --- a/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { Router, RouterModule } from '@angular/router'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; - -import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service'; - -/** Clerk Dashboard "My Share Requests" β€” lists the share withdrawal requests this clerk has - * submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */ -@Component({ - selector: 'mifosx-my-share-withdrawal-requests', - templateUrl: './my-share-withdrawal-requests.component.html', - styleUrls: [ - '../../cashier/cashier-shared.scss', - '../clerk-shared.scss' - ], - standalone: true, - imports: [ - CommonModule, - FormsModule, - RouterModule - ] -}) -export class MyShareWithdrawalRequestsComponent implements OnInit { - private service = inject(ClerkShareWithdrawalService); - private router = inject(Router); - - requests: any[] = []; - totalRecords = 0; - loading = false; - loaded = false; - - filterStatus = ''; - - get statusOptions(): { value: string; label: string }[] { - return [ - { value: '', label: 'All' }, - { value: 'PENDING', label: 'Pending' }, - { value: 'APPROVED', label: 'Approved' }, - { value: 'REJECTED', label: 'Rejected' } - ]; - } - - ngOnInit(): void { - this.load(); - } - - load(): void { - this.loading = true; - this.service - .getMyRequests(this.filterStatus || undefined, 0, 50) - .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) - .subscribe((res: any) => { - this.requests = res?.pageItems ?? []; - this.totalRecords = Number(res?.totalFilteredRecords ?? 0); - this.loading = false; - this.loaded = true; - }); - } - - onFilterChange(): void { - this.load(); - } - - newRequest(): void { - this.router.navigate(['/clerk/share-withdrawal-request']); - } - - statusClass(status: string): string { - const map: Record = { - 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 deleted file mode 100644 index f8c536f33..000000000 --- a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html +++ /dev/null @@ -1,431 +0,0 @@ - - -
-
-
-
{{ isReview ? 'CHECKER Β· APPROVAL' : 'CLERK Β· SHARE WITHDRAWAL' }}
-

{{ pageTitle }}

-

{{ pageSubtitle }}

-
-
- -
-
- -
- -
- @if (!isReview) { - - } - @if (account) { - - - - - } - @if (loadingAccount) { -
Loading account…
- } -
-
- - @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) { -
- -
- - - - - - - - -
- - @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 deleted file mode 100644 index 140ccec89..000000000 --- a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -.swr-modal-backdrop { - position: fixed; - inset: 0; - background: rgb(13 27 42 / 55%); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.swr-modal { - background: #fff; - border-radius: 12px; - padding: 20px; - width: 420px; - max-width: 90vw; - max-height: 85vh; - overflow-y: auto; - box-shadow: 0 12px 32px rgb(15 23 42 / 20%); - - h3 { - margin: 0 0 12px; - font-size: 16px; - color: #0d1b2a; - } - - textarea { - width: 100%; - padding: 8px 10px; - border: 1px solid #e2e8f0; - border-radius: 8px; - font-family: inherit; - font-size: 13px; - resize: vertical; - box-sizing: border-box; - } - - &:has(table) { - width: 560px; - } -} - -.swr-check-pass { - color: #1a7f37; - font-weight: 600; -} - -.swr-check-fail { - color: #b42318; - font-weight: 600; -} - -.swr-chip { - display: inline-flex; - align-items: center; - width: fit-content; - padding: 4px 10px; - border-radius: 999px; - font-size: 12px; - font-weight: 600; - - &--ok { - background: #eafaf0; - color: #1a7f37; - } - - &--warn { - background: #fdecea; - color: #b42318; - } -} - -/* ---------- Verification trigger card ---------- */ -.swrv-trigger { - display: flex; - align-items: center; - gap: 14px; - padding: 16px; - flex-wrap: wrap; - - &__icon { - width: 42px; - height: 42px; - border-radius: 12px; - background: linear-gradient(135deg, #3457d5, #6c8cff); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - box-shadow: 0 4px 12px rgb(52 87 213 / 30%); - } - - &__text { - flex: 1; - min-width: 200px; - } - - &__title { - font-size: 13px; - font-weight: 700; - color: #0d1b2a; - } - - &__sub { - font-size: 12px; - color: #64748b; - margin-top: 2px; - line-height: 1.4; - } -} - -/* ---------- Validate pipeline modal ---------- */ -@keyframes swrv-fade { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes swrv-zoom { - from { - opacity: 0; - transform: scale(0.95) translateY(8px); - } - - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -@keyframes swrv-spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -.swrv-spin { - animation: swrv-spin 0.9s linear infinite; -} - -.swrv-modal { - position: fixed; - inset: 0; - // No backdrop-filter: it would make this element a containing block for any position:fixed - // descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its - // own later) β€” see the identical fix in share-withdrawal-requests.component.scss for the bug this - // caused. Extra opacity compensates for the loss of the blur look. - background: rgb(15 25 45 / 65%); - z-index: 1200; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - animation: swrv-fade 0.2s ease-out; - - &__card { - background: #fff; - border-radius: 16px; - width: 100%; - max-width: 520px; - max-height: 90vh; - display: flex; - flex-direction: column; - overflow: hidden; - box-shadow: 0 24px 60px rgb(0 0 0 / 25%); - animation: swrv-zoom 0.25s ease-out; - } - - &__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 22px; - border-bottom: 1px solid #eef1f5; - } - - &__title { - display: flex; - align-items: center; - gap: 10px; - font-size: 15px; - font-weight: 700; - color: #0d1b2a; - } - - &__title-icon { - width: 34px; - height: 34px; - border-radius: 10px; - background: linear-gradient(135deg, #3457d5, #6c8cff); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - } - - &__close { - width: 32px; - height: 32px; - border-radius: 8px; - border: 1px solid #e2e8f0; - background: #fff; - color: #64748b; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.12s; - - &:not(:disabled):hover { - color: #b42318; - border-color: #b42318; - background: #fdecea; - } - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } - } - - &__body { - padding: 20px 22px; - overflow-y: auto; - flex: 1; - } - - &__intro { - font-size: 13px; - color: #475569; - line-height: 1.55; - margin: 0 0 16px; - - strong { - color: #0d1b2a; - font-weight: 700; - } - } - - &__warn { - display: flex; - gap: 10px; - padding: 12px 14px; - background: #fef3e2; - border: 1px solid rgb(217 119 6 / 30%); - border-left: 3px solid #d97706; - border-radius: 8px; - margin-bottom: 16px; - font-size: 12px; - color: #633806; - line-height: 1.5; - - mat-icon { - color: #d97706; - font-size: 18px; - width: 18px; - height: 18px; - flex-shrink: 0; - } - - strong { - display: block; - font-weight: 700; - color: #4d2a05; - } - } - - &__result { - display: flex; - align-items: center; - gap: 8px; - margin-top: 6px; - padding: 12px 14px; - background: #eafaf0; - border: 1px solid rgb(26 127 55 / 25%); - border-radius: 8px; - font-size: 13px; - font-weight: 600; - color: #1a7f37; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } - - &__footer { - padding: 16px 22px; - border-top: 1px solid #eef1f5; - display: flex; - justify-content: flex-end; - gap: 10px; - background: #f8fafc; - } -} - -.swrv-steps { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; -} - -.swrv-step { - display: flex; - gap: 14px; - padding: 10px 0; - position: relative; - - &:not(:last-child)::after { - content: ''; - position: absolute; - left: 15px; - top: 38px; - bottom: -4px; - width: 2px; - background: #e2e8f0; - } - - &__indicator { - width: 32px; - height: 32px; - border-radius: 50%; - background: #fff; - border: 2px solid #e2e8f0; - color: #64748b; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; - flex-shrink: 0; - position: relative; - z-index: 1; - transition: all 0.25s; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - } - } - - &__content { - padding-top: 5px; - flex: 1; - min-width: 0; - } - - &__label { - font-size: 13px; - font-weight: 600; - color: #475569; - } - - &__msg { - font-size: 11px; - color: #94a3b8; - margin-top: 2px; - } - - &--active { - .swrv-step__indicator { - background: #3457d5; - border-color: #3457d5; - color: #fff; - box-shadow: 0 0 0 4px rgb(52 87 213 / 15%); - } - - .swrv-step__label { - color: #3457d5; - } - } - - &--done { - .swrv-step__indicator { - background: #1a7f37; - border-color: #1a7f37; - color: #fff; - } - - &::after { - background: #1a7f37; - } - - .swrv-step__label { - color: #0d1b2a; - } - } - - &--failed { - .swrv-step__indicator { - background: #b42318; - border-color: #b42318; - color: #fff; - } - - .swrv-step__label { - color: #b42318; - } - } -} - -@media (prefers-reduced-motion: reduce) { - .swrv-spin { - animation: none; - } - - .swrv-modal, - .swrv-modal__card { - animation: none; - } -} 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 deleted file mode 100644 index 836bfb920..000000000 --- a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; - -import { SharesService } from '../../shares/shares.service'; -import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service'; -import { CashierService } from '../../cashier/cashier.service'; -import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component'; - -export type ShareWdMode = 'create' | 'review'; - -interface ActivityEntry { - timestamp: string; - action: string; - user: string; - note?: string; -} - -/** - * Share Withdrawal (redeem shares) request β€” maker screen in the Clerk Dashboard. A clerk finds - * the member's Share Account, enters how many shares to withdraw plus the governing Board - * Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY - * entry point a share withdrawal should go through β€” approval server-side executes the real - * "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend). - */ -@Component({ - selector: 'mifosx-share-withdrawal-request', - templateUrl: './share-withdrawal-request.component.html', - styleUrls: [ - '../../cashier/cashier-shared.scss', - '../clerk-shared.scss', - './share-withdrawal-request.component.scss' - ], - standalone: true, - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatIconModule, - MatSnackBarModule, - SearchSelectComponent - ] -}) -export class ShareWithdrawalRequestComponent implements OnInit { - private route = inject(ActivatedRoute); - private router = inject(Router); - private sharesService = inject(SharesService); - private wdService = inject(ClerkShareWithdrawalService); - private cashierService = inject(CashierService); - private snackBar = inject(MatSnackBar); - - /** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode - * and requestId come from these inputs rather than the route, and success/close actions emit - * events instead of navigating away. */ - @Input() embedded = false; - @Input() overrideMode: ShareWdMode | null = null; - @Input() overrideRequestId: number | null = null; - @Output() closeRequested = new EventEmitter(); - @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/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 deleted file mode 100644 index 271f0fd19..000000000 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html +++ /dev/null @@ -1,98 +0,0 @@ - - -
-
- 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 deleted file mode 100644 index 1f121d633..000000000 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, Inject, OnInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; - -import { MatIcon } from '@angular/material/icon'; -import { MatButtonModule } from '@angular/material/button'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { MatDatepickerModule } from '@angular/material/datepicker'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ReactiveFormsModule } from '@angular/forms'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'cbs-client-family-member-child-dialog', - templateUrl: './client-family-member-child-dialog.component.html', - styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'], - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - MatDialogModule, - MatIcon, - MatButtonModule, - MatInputModule, - MatSelectModule, - MatDatepickerModule, - MatTooltipModule - ] -}) -export class ClientFamilyMemberChildDialogComponent implements OnInit { - - childrenForm: UntypedFormGroup; - genderIdOptions: any[]; - maxDate: Date = new Date(); - - constructor( - public dialogRef: MatDialogRef, - @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 6b3c5ea75..d5b1e8492 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,21 +139,6 @@ {{ 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' }} - } -
- } -
-
- } } @@ -462,19 +447,6 @@ 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 49930b556..552c39daf 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, UntypedFormArray, Validators } from '@angular/forms'; +import { UntypedFormGroup, UntypedFormBuilder, 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,8 +58,6 @@ export class ClientFamilyMemberDialogComponent implements OnInit { private dialog = inject(MatDialog); maxDate = new Date(); - - hasChildrenChecked = false; familyMemberForm: UntypedFormGroup; /** Client search */ @@ -199,23 +197,6 @@ 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(); } @@ -244,83 +225,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit { isAlive: [true], marriageDate: [''], marriageAuthority: [''], - linkedClientId: [''], - hasChildren: [false], - children: this.formBuilder.array([]) + linkedClientId: [''] }); - - 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 */ @@ -575,25 +481,6 @@ 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-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index d0fb025da..9d76fa2f7 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 @@ -533,6 +533,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges screeningLogId: number | null = null; /** Snapshot of the name that was last verified β€” used to invalidate verification when name changes */ private verifiedName: string = ''; + /** True while prefillForEdit() is patching historical monthly/annual values β€” see linkMonthlyAnnual(). */ + private suppressMonthlyAnnualLink = false; /** * @param {FormBuilder} formBuilder Form Builder @@ -709,6 +711,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges const data = this.clientData; this.loadExistingSeniorPwdDocuments(); + // Loading an existing client's independently-saved monthly/annual values must not run + // through the auto-calc in linkMonthlyAnnual() β€” patching monthlyIncome then annualIncome + // (or vice versa) would otherwise have each overwrite the other via its valueChanges, + // instead of keeping the exact historical values as saved. + this.suppressMonthlyAnnualLink = true; + // Patch all base controls that exist on the form this.createClientForm.patchValue({ officeId: data.officeId, @@ -743,6 +751,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges ? new Date(data.timeline.submittedOnDate) : this.settingsService.businessDate }); + this.suppressMonthlyAnnualLink = false; // Patch name fields (already created by buildDependencies via legalFormId subscription) const legalFormId = data.legalForm?.id || LegalFormId.PERSON; @@ -957,7 +966,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.linkMonthlyAnnual('monthlyIncome', 'annualIncome'); this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover'); - this.linkIncomeToTurnover(); } /** @@ -994,37 +1002,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges }); } - /** - * Auto-calculates turnover based on income (doubling monthly income for monthly turnover, - * and annual income for annual/yearly turnover). - */ - private linkIncomeToTurnover(): void { - const monthlyIncome = this.createClientForm.get('monthlyIncome'); - const monthlyTurnover = this.createClientForm.get('monthlyTurnover'); - const annualIncome = this.createClientForm.get('annualIncome'); - const annualTurnover = this.createClientForm.get('annualTurnover'); - - if (monthlyIncome && monthlyTurnover) { - monthlyIncome.valueChanges.subscribe((value) => { - if (this.suppressMonthlyAnnualLink) { - return; - } - const num = parseFloat(value); - monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true }); - }); - } - - if (annualIncome && annualTurnover) { - annualIncome.valueChanges.subscribe((value) => { - if (this.suppressMonthlyAnnualLink) { - return; - } - const num = parseFloat(value); - annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true }); - }); - } - } - /** * Sets select dropdown options. */ @@ -2259,26 +2236,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges } // Match monthly income ranges - if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) { + if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) { continue; } - if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) { + if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) { continue; } // Match annual income ranges (evaluated against totalAnnualIncome) - if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) { + if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) { continue; } - if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) { + if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) { continue; } // Match turnover ranges - if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) { + if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) { continue; } - if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) { + if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) { continue; } 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 faa14f689..b8644c83a 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,33 +723,6 @@ 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 - } - - - } -
-
@@ -1437,7 +1410,6 @@
-

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 faa5e7b1a..708188704 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,7 +29,6 @@ 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'; @@ -153,8 +152,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { kycDocumentName: 'KYC Document Type', kycDocumentNumber: 'KYC Document Number', amlCheckStatus: 'AML Check Status', - bannedEntityMatch: 'Banned Entity List Match', - remarks: 'Reason for Re-edit' + bannedEntityMatch: 'Banned Entity List Match' }; /** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value @@ -276,14 +274,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { riskCalculationReason = ''; 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; @@ -433,8 +423,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { aadhaarNumber: [ '', [ - Validators.pattern(/^(\d{12}|[Xβ€’*\s\d]{12,14})$/), - Validators.maxLength(14) + Validators.pattern(/^\d{12}$/), + Validators.maxLength(12) ] ], panAadhaarSeedingStatus: [''], @@ -478,8 +468,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { allowRiskOverride: [false], allowAmlOverride: [false], remarks: [''], - isException: [false], - exceptionReason: [[]], amlCheckStatus: [ 'CLEAR', Validators.required @@ -622,12 +610,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { dormantFlag: 'ACTIVE', accountStatus: 'ACTIVE', form60Type: 'FORM_60', - form15Type: '15G', - isException: false, - exceptionReason: [] + form15Type: '15G' }); - this.persistedIsException = false; - this.persistedExceptionReason = null; this.kycId = ''; this.kycLoaded = false; this.kycStatus = 0; @@ -958,18 +942,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form60Date: parseDate(data.form60Date), form15Applicable: data.form15Applicable || false, form15Type: data.form15Type || '15G', - form15FY: data.form15FY || '', - isException: data.isException || false, - exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] + 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 - } - 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; @@ -1869,21 +1843,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form15Applicable: v.form15Applicable || false, form15Type: v.form15Type || null, form15FY: v.form15FY || 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) - }) + remarks: v.remarks || null }; } @@ -1896,8 +1856,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { v.enterprisePan || v.cinNumber || v.kycDocumentName || - v.form15Applicable || - v.isException + v.form15Applicable ); } diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index d1d486fe8..60dc5b155 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -60,7 +60,6 @@ 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'; @@ -194,11 +193,6 @@ 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 b14258f63..2bc58ab72 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -545,15 +545,6 @@ > {{ 'labels.inputs.Form 60' | translate }} - - Form 121 / TDS - 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 0ef5ca2d5..b3d72602b 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,30 +165,6 @@ export class WelcomeKitDialogComponent implements OnInit { }); } - canUpdateStatus(status: string): boolean { - return status === 'DISPATCHED' || status === 'IN_TRANSIT'; - } - - updateStatus(dispatch: any, newStatus: string) { - const label = newStatus === 'DELIVERED' ? 'Delivered' : 'Returned'; - const confirmed = confirm(`Are you sure you want to mark this as ${label}? This action cannot be undone from the UI.`); - if (!confirmed) { - return; - } - - this.isLoading = true; - this.clientsService.updateWelcomeKitStatus(dispatch.id, newStatus).subscribe({ - next: () => { - this.snackBar.open(`Welcome Kit marked as ${label} successfully`, 'Close', { duration: 3000 }); - this.fetchData(); - }, - error: () => { - this.isLoading = false; - this.snackBar.open(`Failed to mark Welcome Kit as ${label}`, 'Close', { duration: 3000 }); - } - }); - } - getDisplayAddress(dispatch: any): string { const address = dispatch.deliveryAddress || ''; const name = this.welcomeKitsData?.clientName || ''; diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.html b/src/app/clients/clients-view/form121-tab/form121-tab.component.html deleted file mode 100644 index 1b75a798d..000000000 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.html +++ /dev/null @@ -1,428 +0,0 @@ - - -
- @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 deleted file mode 100644 index 4f3666cf1..000000000 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.scss +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -.f121 { - --f121-line: #e5e7eb; - --f121-bg: #f9fafb; - --f121-ink: #111827; - --f121-muted: #6b7280; - --f121-blue: #1d4ed8; - --f121-green: #15803d; - --f121-amber: #b45309; - - padding: 16px; - font-size: 13px; - color: var(--f121-ink); -} - -.f121__loading { - display: flex; - align-items: center; - gap: 12px; - padding: 40px 16px; - color: var(--f121-muted); -} - -/* ── Header ── */ -.f121__head { - display: flex; - flex-wrap: wrap; - gap: 16px; - align-items: flex-end; - justify-content: space-between; - margin-bottom: 16px; -} - -.f121__title { - margin: 0; - font-size: 16px; - font-weight: 700; -} - -.f121__sub { - margin-top: 4px; - color: var(--f121-muted); - font-size: 12px; -} - -.f121__controls { - display: flex; - gap: 16px; - align-items: flex-end; -} - -.f121__field { - display: flex; - flex-direction: column; - gap: 4px; - - > span { - font-size: 10.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--f121-muted); - } - - select { - padding: 7px 10px; - border: 1px solid var(--f121-line); - border-radius: 6px; - background: #fff; - font-size: 13px; - color: inherit; - } -} - -.f121__mode { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 10px; - border: 1px solid var(--f121-line); - border-radius: 6px; - background: var(--f121-bg); - font-weight: 600; - cursor: help; - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - color: var(--f121-muted); - } -} - -/* ── KPI strip ── */ -.f121__kpis { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 10px; - margin-bottom: 16px; -} - -.f121__kpi { - padding: 12px 14px; - border: 1px solid var(--f121-line); - border-left: 3px solid var(--f121-blue); - border-radius: 8px; - background: #fff; - - &--ok { - border-left-color: var(--f121-green); - } - - &--warn { - border-left-color: var(--f121-amber); - } -} - -.f121__kpi-n { - font-size: 19px; - font-weight: 800; - font-variant-numeric: tabular-nums; -} - -.f121__kpi-l { - margin-top: 2px; - font-size: 11px; - color: var(--f121-muted); - line-height: 1.35; -} - -/* ── Notes ── */ -.f121__note { - display: flex; - gap: 10px; - align-items: flex-start; - margin-bottom: 12px; - padding: 11px 13px; - border-radius: 8px; - border-left: 3px solid var(--f121-blue); - background: color-mix(in srgb, var(--f121-blue) 6%, transparent); - line-height: 1.5; - - mat-icon { - flex-shrink: 0; - font-size: 18px; - width: 18px; - height: 18px; - color: var(--f121-blue); - } - - &--warn { - border-left-color: var(--f121-amber); - background: color-mix(in srgb, var(--f121-amber) 8%, transparent); - - mat-icon { - color: var(--f121-amber); - } - } - - &--tip { - border-left-color: var(--f121-green); - background: color-mix(in srgb, var(--f121-green) 7%, transparent); - - mat-icon { - color: var(--f121-green); - } - } -} - -/* ── Cards ── */ -.f121__card { - margin-bottom: 16px; - border: 1px solid var(--f121-line); - border-radius: 10px; - background: #fff; - overflow: hidden; -} - -.f121__card-head { - display: flex; - align-items: center; - gap: 8px; - padding: 11px 14px; - background: var(--f121-bg); - border-bottom: 1px solid var(--f121-line); - font-weight: 700; - font-size: 12.5px; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--f121-muted); - } -} - -.f121__foot { - padding: 10px 14px; - border-top: 1px solid var(--f121-line); - background: var(--f121-bg); - color: var(--f121-muted); - font-size: 11.5px; - line-height: 1.5; -} - -.f121__empty { - padding: 20px 14px; - color: var(--f121-muted); - text-align: center; -} - -/* ── Key/value list ── */ -.f121__kv-row { - display: grid; - grid-template-columns: 190px 1fr; - gap: 12px; - padding: 10px 14px; - border-bottom: 1px solid #f3f4f6; - line-height: 1.5; - - &:last-child { - border-bottom: 0; - } - - > span:first-child { - color: var(--f121-muted); - font-weight: 600; - } - - &--muted > span:last-child { - color: var(--f121-muted); - font-size: 12px; - } - - &--warn > span:last-child { - color: var(--f121-amber); - } -} - -/* ── Tables ── */ -.f121__table-wrap { - overflow-x: auto; -} - -.f121__table { - width: 100%; - border-collapse: collapse; - font-size: 12.5px; - - th { - text-align: left; - padding: 10px 14px; - background: #fff; - border-bottom: 1px solid var(--f121-line); - color: var(--f121-muted); - font-weight: 700; - font-size: 10.5px; - text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; - } - - td { - padding: 10px 14px; - border-bottom: 1px solid #f3f4f6; - vertical-align: top; - } - - tr:last-child td { - border-bottom: 0; - } - - .num { - text-align: right; - font-variant-numeric: tabular-nums; - white-space: nowrap; - } - - .muted { - color: var(--f121-muted); - font-size: 11.5px; - } -} - -.f121__row--warn > td { - background: color-mix(in srgb, var(--f121-amber) 7%, transparent); -} - -.f121__row--total > td { - background: var(--f121-bg); - border-top: 1px solid var(--f121-line); -} - -/* ── Bits ── */ -.f121__pill { - display: inline-flex; - align-items: center; - padding: 2px 9px; - border-radius: 999px; - border: 1px solid var(--f121-line); - background: var(--f121-bg); - color: var(--f121-muted); - font-size: 10.5px; - font-weight: 700; - white-space: nowrap; - - & + & { - margin-left: 5px; - } - - &--ok { - border-color: color-mix(in srgb, var(--f121-green) 35%, transparent); - background: color-mix(in srgb, var(--f121-green) 10%, transparent); - color: var(--f121-green); - } - - &--warn { - border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent); - background: color-mix(in srgb, var(--f121-amber) 12%, transparent); - color: var(--f121-amber); - } -} - -.f121__mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-weight: 600; -} 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 deleted file mode 100644 index e8813db15..000000000 --- a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** Angular Imports */ -import { Component, OnDestroy, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; -import { MatIcon } from '@angular/material/icon'; -import { MatProgressSpinner } from '@angular/material/progress-spinner'; -import { MatTooltip } from '@angular/material/tooltip'; -import { Observable, Subject, forkJoin, of } from 'rxjs'; -import { catchError, switchMap, takeUntil } from 'rxjs/operators'; - -/** Custom Services */ -import { ClientsService } from '../../clients.service'; -import { SavingsService } from 'app/savings/savings.service'; -import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; -import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -/** Domain logic */ -import { - Declaration, - DeclarationForm, - EligibilityResult, - FY_TAX_CONFIG, - InterestCredit, - TdsAccountInput, - TdsMode, - TdsQuarterLine, - TdsSchedule, - ageInFy, - buildSchedule, - checkEligibility, - collectTdsCredits, - declarationFormFor, - formKindFor, - fyOf, - isTdsApplicable, - isWellFormedPan, - normaliseFy, - tdsDeducteeOf, - tdsModeFor -} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute'; - -/** One of the customer's interest-bearing accounts, with its FY figures. */ -export interface Form121AccountRow { - accountNo: string; - productName: string; - /** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */ - kind: string; - /** Interest credited to this account inside the selected FY. */ - interest: number; - /** Tax Fineract has actually withheld on this account inside the FY. */ - tdsDeducted: number; - /** Whether Fineract is currently withholding tax on this account. */ - withHoldTax: boolean; - taxGroup: string | null; - /** When tax on this account settles β€” from the product, not a preference. */ - mode: TdsMode; - /** Interest here attracts TDS at all (a time deposit, not a savings account). */ - tdsApplicable: boolean; - /** This customer is the deductee β€” false when they are a joint holder only. */ - isDeductee: boolean; - /** Who the tax is deducted against, when it is not this customer. */ - deducteeClientId: number | null; -} - -/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */ -function toIso(value: unknown): string | null { - if (Array.isArray(value) && value.length >= 3) { - const [ - y, - m, - d - ] = value as number[]; - return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`; - } - if (typeof value === 'string' && value.trim()) { - const parsed = new Date(value); - if (!isNaN(parsed.getTime())) { - return parsed.toISOString().slice(0, 10); - } - } - return null; -} - -@Component({ - selector: 'mifosx-form121-tab', - templateUrl: './form121-tab.component.html', - styleUrls: ['./form121-tab.component.scss'], - standalone: true, - imports: [ - ...STANDALONE_SHARED_IMPORTS, - CommonModule, - FormsModule, - MatIcon, - MatProgressSpinner, - MatTooltip - ] -}) -export class Form121TabComponent implements OnInit, OnDestroy { - private route = inject(ActivatedRoute); - private clientsService = inject(ClientsService); - private savingsService = inject(SavingsService); - private fixedDepositsService = inject(FixedDepositsService); - private recurringDepositsService = inject(RecurringDepositsService); - - private destroy$ = new Subject(); - - 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 cb198b659..820d4877a 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,19 +42,6 @@ @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. -

-
-
- } -
@@ -753,33 +740,11 @@ - -
- 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 38c5a74f2..2c8e5cef2 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,7 +14,6 @@ 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'; @@ -122,9 +121,7 @@ export class KycTabComponent implements OnInit, OnDestroy { '', [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)] ], - remarks: [''], - isException: [false], - exceptionReason: [[]] + remarks: [''] }); ngOnInit(): void { @@ -160,9 +157,7 @@ export class KycTabComponent implements OnInit, OnDestroy { // the full 12-digit number to re-verify, so don't pre-fill. aadhaarNumber: '', gstinNumber: data.gstinNumber || '', - remarks: data.remarks || '', - isException: data.isException || false, - exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] + remarks: data.remarks || '' }); } }); @@ -200,28 +195,6 @@ export class KycTabComponent implements OnInit, OnDestroy { } } - /** - * Exception columns derived from verification status β€” see `deriveKycExceptionFields()`. - * - * A field counts as verified if it was verified in this session OR is already verified on the - * stored record; either way the customer has cleared that check. Verifying one of two - * outstanding items narrows the exception to the remaining one rather than closing it. - */ - private buildExceptionFields() { - const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true; - const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true; - - return deriveKycExceptionFields({ - panVerified, - aadhaarVerified, - aadhaarApplicable: !this.isNonIndividual, - hadException: this.kycData?.isException === true, - previousReason: this.kycData?.exceptionReason || null, - manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [], - todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy') - }); - } - saveKyc(): void { if (this.kycForm.invalid) { return; @@ -237,10 +210,7 @@ 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, - // dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes. - dateFormat: 'dd MMMM yyyy', - ...this.buildExceptionFields() + remarks: this.kycForm.value.remarks || null }; if (!this.kycId) { @@ -274,37 +244,6 @@ export class KycTabComponent implements OnInit, OnDestroy { }); } - private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void { - if (this.isEditMode) { - const aadhaarRaw = this.kycForm.value.aadhaarNumber || ''; - const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null; - const payload: any = { - locale: 'en', - panNumber: this.kycForm.value.panNumber || null, - aadhaarNumberMasked: aadhaarMasked, - gstinNumber: this.kycForm.value.gstinNumber || null, - remarks: this.kycForm.value.remarks || null, - riskCategory: this.kycForm.value.riskCategory || 1, - dateFormat: 'dd MMMM yyyy', - ...this.buildExceptionFields() - }; - - this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({ - next: () => { - this.loadKycData(); - callback(); - }, - error: (err: any) => { - this.verifying[field] = false; - this.verificationStatus[field] = 'FAILED'; - this.verificationError[field] = err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`; - } - }); - } else { - callback(); - } - } - verifyPAN(): void { if (!this.kycId) { this.verificationError.pan = 'KYC record not found. Save KYC details first.'; @@ -315,33 +254,31 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.pan = null; this.verificationDetails.pan = null; - 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.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 = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'PAN verification failed. Please try again.'; + this.verificationError.pan = 'PAN verification failed.'; } - }); - }, 'pan'); + }, + error: (err: any) => { + this.verifying.pan = false; + this.verificationStatus.pan = 'FAILED'; + this.verificationError.pan = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'PAN verification failed. Please try again.'; + } + }); } /** Show Aadhaar input in summary view for re-verification */ @@ -368,33 +305,31 @@ export class KycTabComponent implements OnInit, OnDestroy { this.aadhaarOtpSent = false; this.aadhaarOtp = ''; - 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'); + 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.'; + } + }); } /** Step 2: Verify OTP entered by user */ @@ -445,33 +380,31 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.gstin = null; this.verificationDetails.gstin = null; - 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.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 = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'GSTIN verification failed. Please try again.'; + this.verificationError.gstin = 'GSTIN verification failed.'; } - }); - }, 'gstin'); + }, + error: (err: any) => { + this.verifying.gstin = false; + this.verificationStatus.gstin = 'FAILED'; + this.verificationError.gstin = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'GSTIN verification failed. Please try again.'; + } + }); } formatDate(date: any): string { diff --git a/src/app/clients/clients.module.ts b/src/app/clients/clients.module.ts index bd13c7eca..5171c0131 100644 --- a/src/app/clients/clients.module.ts +++ b/src/app/clients/clients.module.ts @@ -62,7 +62,6 @@ 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'; @@ -128,7 +127,6 @@ 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 b230aa19e..14a2efc49 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -123,20 +123,6 @@ 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 }); @@ -972,33 +958,4 @@ 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 }); - } - - 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 2f144a5fe..52535f087 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -430,22 +430,8 @@ export class CreateClientComponent { } this.validationError = ''; // Save KYC - 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: () => {} }); - } + if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { + this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} }); } // Upload photo & signature if (this.clientKycStep?.photographFile) { @@ -710,20 +696,12 @@ export class CreateClientComponent { // Persist the KYC document picker selection + uploaded files as a client identifier this.saveKycDocProof(clientId); - // 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() - }); - } + // 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() + }); } else { navigateToClient(); } diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index ab2a69972..92740d87c 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -84,70 +84,14 @@ 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; @@ -301,10 +254,6 @@ 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; @@ -490,11 +439,7 @@ 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) { - if (this.oldMobileNotAccessible && this.firDocumentId) { - (clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId; - } else { - clientData.mobileOldOtpId = this.oldMobileOtpId; - } + clientData.mobileOldOtpId = this.oldMobileOtpId; clientData.mobileNewOtpId = this.newMobileOtpId; } if (changedFields.emailChanged) { @@ -685,34 +630,25 @@ export class EditClientComponent { private detectSensitiveFieldChanges(originalData: any) { const form = this.clientGeneralForm; - 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; + // 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. const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || ''; - const oldMobile = (originalData.mobileNo || '').toString().trim(); - const oldEmail = (originalData.emailAddress || '').toString().trim(); + const oldMobile = (originalData.mobileNo || '').trim(); + const oldEmail = (originalData.emailAddress || '').trim(); const oldDobRaw = originalData.dateOfBirth; const oldDob = Array.isArray(oldDobRaw) ? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}` @@ -733,10 +669,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 || '', @@ -745,21 +681,12 @@ 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, - emailChanged, + 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(), dobChanged: newDob !== oldDob, oldName, newName, diff --git a/src/app/clients/kyc-exception.util.spec.ts b/src/app/clients/kyc-exception.util.spec.ts deleted file mode 100644 index 1c429ff78..000000000 --- a/src/app/clients/kyc-exception.util.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { - deriveKycExceptionFields, - KycExceptionInput, - KYC_EXCEPTION_AADHAAR, - KYC_EXCEPTION_PAN -} from './kyc-exception.util'; - -const TODAY = '05 August 2026'; - -/** Individual client, nothing previously recorded β€” the client-creation case. */ -const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({ - panVerified, - aadhaarVerified, - aadhaarApplicable: true, - hadException: false, - previousReason: null, - todayFormatted: TODAY -}); - -/** Individual client that already carries an open exception. */ -const existingException = ( - panVerified: boolean, - aadhaarVerified: boolean, - previousReason: string -): KycExceptionInput => ({ - panVerified, - aadhaarVerified, - aadhaarApplicable: true, - hadException: true, - previousReason, - todayFormatted: TODAY -}); - -describe('deriveKycExceptionFields', () => { - describe('at client creation', () => { - it('marks both PAN and Aadhaar when neither is entered or verified', () => { - expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({ - isException: true, - exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`, - exceptionResolved: false, - exceptionResolvedOn: null - }); - }); - - it('marks only PAN when Aadhaar verified but PAN did not', () => { - expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({ - isException: true, - exceptionReason: KYC_EXCEPTION_PAN, - exceptionResolved: false, - exceptionResolvedOn: null - }); - }); - - it('marks only Aadhaar when PAN verified but Aadhaar did not', () => { - expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({ - isException: true, - exceptionReason: KYC_EXCEPTION_AADHAAR, - exceptionResolved: false, - exceptionResolvedOn: null - }); - }); - - it('raises nothing when both verify, and does not mark the client resolved', () => { - // A clean client never had an exception, so it must not appear in the Resolved tab either. - expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({ - isException: false, - exceptionReason: null, - exceptionResolved: false, - exceptionResolvedOn: null - }); - }); - - it('does not raise an Aadhaar exception for a non-individual client', () => { - // Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception. - expect( - deriveKycExceptionFields({ - panVerified: true, - aadhaarVerified: false, - aadhaarApplicable: false, - hadException: false, - previousReason: null, - todayFormatted: TODAY - }).isException - ).toBe(false); - }); - }); - - describe('resolving', () => { - it('closes the exception once both checks pass, keeping the reason for the audit trail', () => { - const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; - expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({ - isException: false, - exceptionReason: both, - exceptionResolved: true, - exceptionResolvedOn: TODAY - }); - }); - - it('narrows to the outstanding item when only one of two verifies', () => { - const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; - // PAN verified, Aadhaar still outstanding β€” stays open, listing Aadhaar alone. - expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({ - isException: true, - exceptionReason: KYC_EXCEPTION_AADHAAR, - exceptionResolved: false, - exceptionResolvedOn: null - }); - }); - - it('narrows the other way round too', () => { - const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; - expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN); - }); - }); - - describe('re-opening', () => { - it('clears a prior resolution so a new exception returns to Pending', () => { - // Regression: without exceptionResolved=false the record still matched the Resolved query - // and a brand-new exception appeared as already closed. - const result = deriveKycExceptionFields({ - panVerified: false, - aadhaarVerified: true, - aadhaarApplicable: true, - hadException: false, - previousReason: 'PAN Card Verification Failed', - todayFormatted: TODAY - }); - expect(result.exceptionResolved).toBe(false); - expect(result.exceptionResolvedOn).toBeNull(); - expect(result.isException).toBe(true); - }); - }); - - describe('officer-entered reasons', () => { - it('keeps a manual reason alongside the automatic ones', () => { - const result = deriveKycExceptionFields({ - ...newIndividual(true, true), - manualReasons: ['Address proof pending'] - }); - expect(result).toEqual({ - isException: true, - exceptionReason: 'Address proof pending', - exceptionResolved: false, - exceptionResolvedOn: null - }); - }); - - it('drops a stale automatic reason left in the form once the check passes', () => { - // The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since - // verified, so it must not survive into the payload. - const result = deriveKycExceptionFields({ - ...newIndividual(true, true), - manualReasons: [KYC_EXCEPTION_PAN] - }); - expect(result.isException).toBe(false); - expect(result.exceptionReason).toBeNull(); - }); - - it('does not duplicate a manual reason that matches an outstanding automatic one', () => { - const result = deriveKycExceptionFields({ - ...newIndividual(false, true), - manualReasons: [KYC_EXCEPTION_PAN] - }); - expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN); - }); - }); -}); diff --git a/src/app/clients/kyc-exception.util.ts b/src/app/clients/kyc-exception.util.ts deleted file mode 100644 index 9d5f4185b..000000000 --- a/src/app/clients/kyc-exception.util.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** - * Derives a client's KYC exception state from their verification status. - * - * A KYC exception is not something an officer should have to remember to tick β€” it is simply the - * fact that a mandatory identity check has not passed. Deriving it means the exception list can - * never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN - * and Aadhaar are verified at the moment of the save. - * - * Shared by the client-creation stepper and the client-view KYC tab so both write identical state. - */ - -export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed'; -export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed'; - -/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */ -export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [ - KYC_EXCEPTION_PAN, - KYC_EXCEPTION_AADHAAR -]; - -export interface KycExceptionInput { - /** PAN verified against the Income Tax database. Unverified OR simply not entered β†’ exception. */ - panVerified: boolean; - /** Aadhaar verified against UIDAI. Unverified OR simply not entered β†’ exception. */ - aadhaarVerified: boolean; - /** - * False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy - * masked value). Without this guard every company would carry a permanent Aadhaar exception. - */ - aadhaarApplicable: boolean; - /** Whether an exception is already recorded β€” decides raise vs. resolve on a clean save. */ - hadException: boolean; - /** Reason currently persisted, kept verbatim when closing so the audit trail survives. */ - previousReason: string | null; - /** Officer-entered reasons beyond the two automatic ones; carried through untouched. */ - manualReasons?: string[]; - /** Today in the tenant's date format β€” stamped as exceptionResolvedOn when closing. */ - todayFormatted: string; -} - -export interface KycExceptionFields { - isException: boolean; - exceptionReason: string | null; - exceptionResolved: boolean; - exceptionResolvedOn: string | null; -} - -/** - * Maps verification status to the four exception columns the API expects. - * - * Three outcomes: - * - **Something outstanding** β†’ raise/keep the exception, listing only what is still failing. A - * record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with - * Aadhaar alone, which is what narrows the entry rather than closing it. - * - **Nothing outstanding, exception existed** β†’ close it. `exceptionReason` is retained on - * purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of - * why the exception was ever raised. - * - **Nothing outstanding, no exception** β†’ leave every flag off, so a clean client never appears - * in either tab. - */ -export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields { - const outstanding: string[] = []; - - if (!input.panVerified) { - outstanding.push(KYC_EXCEPTION_PAN); - } - if (input.aadhaarApplicable && !input.aadhaarVerified) { - outstanding.push(KYC_EXCEPTION_AADHAAR); - } - - // Officer-added reasons ride along, but the two automatic ones are filtered out first β€” a stale - // 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified. - for (const reason of input.manualReasons || []) { - const trimmed = (reason || '').trim(); - if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) { - outstanding.push(trimmed); - } - } - - if (outstanding.length > 0) { - return { - isException: true, - exceptionReason: outstanding.join(', '), - exceptionResolved: false, - exceptionResolvedOn: null - }; - } - - if (input.hadException) { - return { - isException: false, - exceptionReason: input.previousReason || null, - exceptionResolved: true, - exceptionResolvedOn: input.todayFormatted - }; - } - - return { - isException: false, - exceptionReason: null, - exceptionResolved: false, - exceptionResolvedOn: null - }; -} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 4127609cb..c99ee3075 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -99,19 +99,6 @@ My Disbursements
- - - - - - My Share Requests - @if (clerkAccountLoaded) { @@ -1353,27 +1403,27 @@
Core & Books
- - - - - - @@ -1382,19 +1432,19 @@
Bank & Clearing
- - - - @@ -1403,15 +1453,15 @@
Digital & Payments
- - - @@ -1424,7 +1474,7 @@ DD / Pay Order Reconciliation - @@ -1433,11 +1483,11 @@
diff --git a/src/app/core/shell/shell.component.ts b/src/app/core/shell/shell.component.ts index 9ce65daac..0ceee8562 100644 --- a/src/app/core/shell/shell.component.ts +++ b/src/app/core/shell/shell.component.ts @@ -188,9 +188,9 @@ export class ShellComponent implements OnInit, OnDestroy { return this.router.url; } - /** True on the manager's full-screen withdrawal-request (cash or share) review screen. */ + /** True on the manager's full-screen withdrawal-request review screen. */ get isWithdrawalReviewUrl(): boolean { - return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url); + return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url); } /** ESC key β€” exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */ diff --git a/src/app/kyc-management/kyc-management.component.html b/src/app/kyc-management/kyc-management.component.html index 256f9b7ec..aaf28f3ed 100644 --- a/src/app/kyc-management/kyc-management.component.html +++ b/src/app/kyc-management/kyc-management.component.html @@ -29,11 +29,6 @@ Account Status 34 -
@@ -727,284 +722,6 @@
} - - - @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 - -
- } - } -
-
- }
@@ -1448,251 +1165,6 @@
} - - @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 7bff6b323..bef8750f6 100644 --- a/src/app/kyc-management/kyc-management.component.scss +++ b/src/app/kyc-management/kyc-management.component.scss @@ -1588,58 +1588,6 @@ 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); @@ -1700,28 +1648,3 @@ 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 76d33091a..b3aa34bae 100644 --- a/src/app/kyc-management/kyc-management.component.ts +++ b/src/app/kyc-management/kyc-management.component.ts @@ -6,11 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { ClientsService } from '../clients/clients.service'; interface KmNotice { no: string; @@ -32,24 +31,6 @@ 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; @@ -85,17 +66,6 @@ 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({ @@ -109,8 +79,7 @@ interface KmClient { ] }) export class KycManagementComponent implements OnInit { - private clientsService = inject(ClientsService); - activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts'; + activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts'; modalOpen = false; modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview'; @@ -560,399 +529,15 @@ 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(); } - 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 { + showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): 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 88a5cecc4..91aebd362 100644 --- a/src/app/letter-mgmt/certificates/certificate-pdf.ts +++ b/src/app/letter-mgmt/certificates/certificate-pdf.ts @@ -1069,10 +1069,12 @@ 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 45919091c..a0b6381fd 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 }} +
+
To,
+
+
+ {{ interest.record.customerName }} +
+
+ Customer ID: {{ interest.record.customerId }} Β· PAN: + {{ interest.record.customerPan }} +
+
{{ interest.record.customerAddress }}
-
- 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 }} +
+
Issued To,
+
+
+ {{ share.memberName }} +
+
+ Member ID: {{ share.memberId }} Β· PAN: + {{ share.memberPan }} +
+
{{ share.memberAddress }}
-
- 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 }} +
+
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 }}
+ }
-
- 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 }} +
+
Issued To,
+
+
+ {{ hypothecation.customerName }} +
+
+ Customer ID: {{ hypothecation.customerId }} Β· PAN: + {{ hypothecation.customerPan }} +
+
{{ hypothecation.customerAddress }}
-
- 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 }} +
+
Issued To,
+
+
+ {{ noc.customerName }} +
+
+ Customer ID: {{ noc.customerId }} Β· PAN: + {{ noc.customerPan }} +
+
{{ noc.customerAddress }}
-
- 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) { - - - - - - +

+ 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 } - @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
+ 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.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.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.status === 'ISSUED' && noc.issuedOn) { -

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

+ @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 fc05c778c..ed8bea4d2 100644 --- a/src/app/letter-mgmt/certificates/certificates.shared.scss +++ b/src/app/letter-mgmt/certificates/certificates.shared.scss @@ -505,72 +505,3 @@ 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 07b30a3e3..417b1525d 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,60 +11,26 @@
-

{{ 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. - } -
+

Form 121

+
TDS-waiver declarations for FD / RD interest income β€” Form 121.
-
- @if (viewMode === 'demo') { - - } @else { - - } -
- @if (viewMode === 'real') { - - } - - @if (viewMode === 'demo') { - +
-
{{ countGeneralBand() | number }}
-
General band (under 60)
+
{{ count15G() | number }}
+
Form 121 (under 60)
-
{{ countSeniorBand() | number }}
-
Senior citizen band (60+)
+
{{ count15H() | number }}
+
Form 121 (60+)
@@ -81,22 +47,6 @@
Projected Interest
-
- -
-
β‚Ή{{ totalTdsSaved() | number: '1.0-0' }}
-
TDS Saved
-
-
- @if (countIneligible() > 0) { -
- -
-
{{ countIneligible() | number }}
-
Not Eligible
-
-
- }
@@ -110,11 +60,11 @@
- - + + +
@@ -127,15 +77,7 @@
-
- - -
- - @if (search || bandFilter || statusFilter || ineligibleOnly) { + @if (search || kindFilter || statusFilter) { }
@@ -147,92 +89,54 @@ Customer Β· PAN - Form Β· Band + Form Type Age Branch - Est. Interest - Threshold - TDS Payable - TDS Saved - Settlement + Deposit Principal + Est. Interest ({{ records[0]?.fy }}) Status Actions - @for (row of filteredRows; track row.record.customerId) { - + @for (r of filteredRecords; track r.customerId) { + -
{{ row.record.customerName }}
-
{{ row.record.customerId }} Β· PAN {{ row.record.customerPan }}
-
DOB {{ row.record.dob | date: 'd MMM y' }}
+
{{ r.customerName }}
+
{{ r.customerId }} Β· PAN {{ r.customerPan }}
+
DOB {{ r.dob | date: 'd MMM y' }}
- {{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }} + Form 121 ({{ formKind(r) }}) -
{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}
- {{ row.age }} yrs - {{ row.record.branch }} + {{ age(r.dob) }} yrs + {{ r.branch }} + β‚Ή{{ r.depositPrincipal | 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
+ β‚Ή{{ r.estimatedInterest | number: '1.0-0' }} - @if (row.record.status === 'FILED') { + @if (r.status === 'FILED') { Filed - @if (row.record.filedOn) { -
{{ row.record.filedOn | date: 'd MMM y' }}
+ @if (r.filedOn) { +
{{ r.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 (filteredRows.length === 0) { + @if (filteredRecords.length === 0) { - + No declarations match the current filters. @@ -351,5 +165,4 @@
-} 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 9ab9b9b5d..839a9fb6f 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,31 +21,9 @@ 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'; -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 Form15Kind = '15G' | '15H'; 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; @@ -54,46 +32,18 @@ export interface Form15ghRecord { customerAddress: string; dob: string; branch: string; - /** Aggregate interest the bank will pay this FY across the customer's time deposits. */ + /** Aggregate interest the bank will pay this FY. */ estimatedInterest: number; - /** Customer's total estimated income for the FY, as declared by them. */ + /** Customer's total estimated income for the FY. */ 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({ @@ -105,8 +55,7 @@ export interface Form15ghRow { CommonModule, FormsModule, FaIconComponent, - MatTooltip, - RealForm15ghViewComponent + MatTooltip ] }) export class Form15ghComponent implements OnInit { @@ -114,89 +63,40 @@ 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 deduction trigger. Empty = both bands. */ - bandFilter: '' | ThresholdBand = ''; + /** Which form. Empty = both. */ + kindFilter: '' | Form15Kind = ''; statusFilter: '' | Form15Status = ''; - /** Show only declarations the branch cannot lawfully accept. */ - ineligibleOnly = false; - /** Which row's quarter-wise breakdown is open. */ - expandedId: string | null = null; - - rows: Form15ghRow[] = []; + records: Form15ghRecord[] = []; ngOnInit(): void { - // 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)); + this.records = seedForm15gh(); } - 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 - }) - }; + /** 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; } - get filteredRows(): Form15ghRow[] { + /** 15H once 60+, 15G otherwise. */ + formKind(r: Form15ghRecord): Form15Kind { + return this.age(r.dob) >= 60 ? '15H' : '15G'; + } + + get filteredRecords(): Form15ghRecord[] { const q = this.search.trim().toLowerCase(); - 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 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 ( r.customerName.toLowerCase().includes(q) || r.customerId.toLowerCase().includes(q) || @@ -205,67 +105,32 @@ export class Form15ghComponent implements OnInit { }); } - countGeneralBand(): number { - return this.rows.filter((r) => r.band === 'GENERAL').length; + count15G(): number { + return this.records.filter((r) => this.formKind(r) === '15G').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' - }; + count15H(): number { + return this.records.filter((r) => this.formKind(r) === '15H').length; } countFiled(): number { - 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; + return this.records.filter((r) => r.status === 'FILED').length; } totalInterest(): number { - 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); + return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0); } clearFilters(): void { this.search = ''; - this.bandFilter = ''; + this.kindFilter = ''; this.statusFilter = ''; - this.ineligibleOnly = false; } - 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 { + viewCertificate(r: Form15ghRecord): void { const data: CertificatePreviewDialogData = { kind: 'form15gh', - form15gh: { record: row.record, kind: row.kind, age: row.age } + form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) } }; this.dialog.open(CertificatePreviewDialogComponent, { width: '880px', @@ -277,34 +142,20 @@ export class Form15ghComponent implements OnInit { }); } - 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; - } + downloadCertificate(r: Form15ghRecord): void { + const kind = this.formKind(r); this.dscSign.promptThenDownload( { - title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`, + title: `Form 121 (${kind}) Declaration`, reference: `${r.customerId} Β· ${r.customerName} Β· ${r.fy}`, filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf` }, - (dsc) => downloadForm15ghPdf(r, row.kind, dsc) + (dsc) => downloadForm15ghPdf(r, kind, dsc) ); } - - toggleViewMode(): void { - this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo'; - } } -// ───────────────────────── Mock seed (top-level export) ───────────────────── -/** - * 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. - */ +// ───────────────────── Mock seed (top-level export) ───────────────────── export function seedForm15gh(): Form15ghRecord[] { return [ { @@ -314,109 +165,86 @@ export function seedForm15gh(): Form15ghRecord[] { customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', dob: '1985-02-14', branch: 'Mumbai β€” Andheri (E)', - 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' + estimatedInterest: 38500, + estimatedIncome: 850000, + depositPrincipal: 500000, + fy: 'FY 2025-26', + status: 'FILED', + filedOn: '2025-04-10', + assessed: true }, { customerId: 'CUST556677', customerName: 'Karthik Rao', - customerPan: 'PQRPR2233Z', + customerPan: 'PQRSR2233Z', customerAddress: '24, MG Road, Bengaluru 560001', dob: '1972-09-04', branch: 'Bengaluru β€” MG Road', - estimatedInterest: 52_800, - estimatedIncome: 320_000, - depositPrincipal: 700_000, - fy: 'FY 2026-27', + estimatedInterest: 52800, + estimatedIncome: 320000, + depositPrincipal: 700000, + fy: 'FY 2025-26', status: 'PENDING', - assessed: false, - interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' + assessed: false }, { customerId: 'CUST667788', customerName: 'Lakshmi Naidu', - customerPan: 'XYZPN7788Q', + customerPan: 'XYZAN7788Q', customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', dob: '1958-06-22', branch: 'Hyderabad β€” Banjara Hills', - estimatedInterest: 88_200, - estimatedIncome: 410_000, - depositPrincipal: 1_200_000, - fy: 'FY 2026-27', + estimatedInterest: 88200, + estimatedIncome: 410000, + depositPrincipal: 1200000, + fy: 'FY 2025-26', status: 'FILED', - filedOn: '2026-04-15', - assessed: true, - interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' + filedOn: '2025-04-15', + assessed: true }, { customerId: 'CUST334455', customerName: 'Anita Verma', - customerPan: 'EFGPV5678A', + customerPan: 'EFGHV5678A', customerAddress: 'B-12, Connaught Pl, New Delhi 110001', dob: '1962-11-30', branch: 'New Delhi β€” Connaught Place', - estimatedInterest: 64_500, - estimatedIncome: 280_000, - depositPrincipal: 900_000, - fy: 'FY 2026-27', + estimatedInterest: 64500, + estimatedIncome: 280000, + depositPrincipal: 900000, + fy: 'FY 2025-26', status: 'FILED', - filedOn: '2026-04-05', - assessed: false, - interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual' + filedOn: '2025-04-05', + assessed: false }, { customerId: 'CUST990011', customerName: 'Arjun Banerjee', - customerPan: 'TUVPB4455F', + customerPan: 'TUVWB4455F', customerAddress: '42, Park Street, Kolkata 700016', dob: '1991-07-19', branch: 'Kolkata β€” Park Street', - estimatedInterest: 14_200, - // Over the basic exemption limit β€” not eligible for 15G. - estimatedIncome: 620_000, - depositPrincipal: 200_000, - fy: 'FY 2026-27', + estimatedInterest: 14200, + estimatedIncome: 620000, + depositPrincipal: 200000, + fy: 'FY 2025-26', status: 'PENDING', - assessed: true, - interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly' + assessed: true }, { customerId: 'CUST330044', customerName: 'Madhavi Singh', - customerPan: 'TUVPS3344K', + customerPan: 'TUVWS3344K', customerAddress: 'D-5, Greater Kailash, New Delhi 110048', dob: '1955-03-08', branch: 'New Delhi β€” Connaught Place', - // 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', + estimatedInterest: 145000, + estimatedIncome: 320000, + depositPrincipal: 2000000, + fy: 'FY 2025-26', status: 'FILED', - 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' + filedOn: '2025-04-02', + assessed: false } ]; } 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 deleted file mode 100644 index bc34a7490..000000000 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html +++ /dev/null @@ -1,329 +0,0 @@ - - -@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 deleted file mode 100644 index 1793ce61c..000000000 --- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts +++ /dev/null @@ -1,502 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, OnInit, OnDestroy, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { MatTooltip } from '@angular/material/tooltip'; -import { forkJoin, of, Observable, Subject } from 'rxjs'; -import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -import { ClientsService } from '../../../../clients/clients.service'; -import { SavingsService } from '../../../../savings/savings.service'; -import { FixedDepositsService } from '../../../../deposits/fixed-deposits/fixed-deposits.service'; -import { RecurringDepositsService } from '../../../../deposits/recurring-deposits/recurring-deposits.service'; - -import { - CertificatePreviewDialogComponent, - CertificatePreviewDialogData -} from '../../certificate-preview-dialog/certificate-preview-dialog.component'; -import { downloadForm15ghPdf } from '../../certificate-pdf'; -import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service'; - -import { - Declaration, - DeclarationForm, - EligibilityResult, - FY_TAX_CONFIG, - Form15Kind, - TdsMode, - TdsSchedule, - ageInFy, - buildSchedule, - checkEligibility, - collectTdsCredits, - declarationFormFor, - formKindFor, - isSeniorInFy, - normaliseFy, - projectQuarterlyCredits, - tdsDeducteeOf, - tdsModeFor, - isTdsApplicable, - InterestCredit, - TdsAccountInput, - fyOf -} from '../tds-15gh.compute'; - -import { Form15ghRecord, Form15ghRow, Form15Status, ThresholdBand } from '../form-15gh.component'; - -export interface RealForm15ghRow { - row: Form15ghRow; - clientId: string; -} - -/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */ -function toIso(value: unknown): string | null { - if (Array.isArray(value) && value.length >= 3) { - const [y, m, d] = value as number[]; - return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`; - } - if (typeof value === 'string' && value.trim()) { - const parsed = new Date(value); - if (!isNaN(parsed.getTime())) { - return parsed.toISOString().slice(0, 10); - } - } - return null; -} - -@Component({ - selector: 'mifosx-real-form15gh-view', - templateUrl: './real-form15gh-view.component.html', - styleUrls: ['../../certificates.shared.scss'], - imports: [ - ...STANDALONE_SHARED_IMPORTS, - CommonModule, - FormsModule, - FaIconComponent, - MatTooltip - ] -}) -export class RealForm15ghViewComponent implements OnInit, OnDestroy { - private dialog = inject(MatDialog); - private snack = inject(MatSnackBar); - private clientsService = inject(ClientsService); - private savingsService = inject(SavingsService); - private fixedDepositsService = inject(FixedDepositsService); - private recurringDepositsService = inject(RecurringDepositsService); - private dscSign = inject(DscSignService); - - private destroy$ = new Subject(); - - 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 deleted file mode 100644 index 636979647..000000000 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts +++ /dev/null @@ -1,766 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { - FY_TAX_CONFIG, - InterestCredit, - TdsAccountInput, - TdsContext, - ageInFy, - declarationFormFor, - annualSchedule, - bucketByQuarter, - checkEligibility, - collectTdsCredits, - cumulativeLiability, - formKindFor, - fyOf, - isTdsApplicable, - isWellFormedPan, - lateDeductionInterest, - monthsOrPartBetween, - projectQuarterlyCredits, - quarterlySchedule, - reconcile, - tdsDeducteeOf, - tdsModeFor -} from './tds-15gh.compute'; - -const FY = 'FY 2026-27'; - -/** Under 60 for FY 2026-27, valid individual PAN. */ -function general(overrides: Partial = {}): TdsContext { - 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 deleted file mode 100644 index 32ee84d6d..000000000 --- a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts +++ /dev/null @@ -1,1133 +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/. - */ - -/** - * 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/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html index 639cfbe47..d2db28c86 100644 --- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html +++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.html @@ -115,51 +115,6 @@ } - -
-
-

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 986472df6..88e88a51d 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,11 +50,6 @@ 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', @@ -83,11 +78,6 @@ 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() { @@ -108,8 +98,6 @@ 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 @@ -117,7 +105,6 @@ export class SavingProductAccountingStepComponent implements OnInit { this.syncAccountingControls(accountingRuleId); this.patchAccountingValues(mappings); - this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []); this.setOverdraftControl(); this.setDormancyControl(); } @@ -128,48 +115,10 @@ export class SavingProductAccountingStepComponent implements OnInit { assetsOtherGlMappings: this.formBuilder.array([]), liabilitiesOtherGlMappings: this.formBuilder.array([]), expensesOtherGlMappings: this.formBuilder.array([]), - incomeOtherGlMappings: this.formBuilder.array([]), - paymentChannelToFundSourceMappings: this.formBuilder.array([]) + incomeOtherGlMappings: 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 14693833d..cd20da7bc 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,12 +74,6 @@ 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(); @@ -91,7 +85,6 @@ 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(); @@ -142,10 +135,10 @@ export class ShareProductAccountingStepComponent implements OnInit { rows: [ { key: 'shareReferenceId', - label: 'Share Reference (Cash Vault or Suspense)', + label: 'Share Reference (Cash Vault)', inputLabel: 'Share reference', controlName: 'shareReferenceId', - options: this.referenceAccountData, + options: this.assetAccountData, required: true } ], diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 7565d21ec..602c49d5f 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -59,18 +59,6 @@ const routes: Routes = [ loadComponent: () => import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent) }, - { - path: 'tds-interest', - data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' }, - loadComponent: () => - import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent) - }, - { - path: 'tds-remittance', - data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' }, - loadComponent: () => - import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) - }, { path: ':filter', data: { routeParamBreadcrumb: 'filter' }, diff --git a/src/app/shared/search-select/search-select.component.ts b/src/app/shared/search-select/search-select.component.ts deleted file mode 100644 index 2a7e27558..000000000 --- a/src/app/shared/search-select/search-select.component.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; - -export interface SearchSelectOption { - value: string; - label: string; - /** What typed text is matched against β€” defaults to `label` when omitted, but a caller can - * pass a richer string (e.g. account no + customer name + branch) so a search hits fields - * that aren't all visible in the collapsed label. */ - searchText?: string; -} - -/** - * Drop-in replacement for a plain ` - @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/share-holders/share-holders.component.html b/src/app/shares/share-holders/share-holders.component.html index 15adb5d11..daae3fe22 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 27c01a151..6d41b20a0 100644 --- a/src/app/shares/share-holders/share-holders.component.scss +++ b/src/app/shares/share-holders/share-holders.component.scss @@ -5,43 +5,37 @@ * 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 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; + margin: 1.5rem; + padding: 0; + border-radius: var(--radius-xl); + background: var(--bg-card); + box-shadow: var(--shadow-sm); } .table-header { - margin-bottom: 20px; + padding: 1.5rem; + border-bottom: 1px solid var(--border-light); .table-header-row { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 16px; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; } .table-title-group { display: flex; align-items: center; - gap: 12px; + gap: 1rem; .table-title { margin: 0; font-size: 1.25rem; font-weight: 600; - color: #0f172a; + color: var(--text-main); display: flex; align-items: center; gap: 0.75rem; @@ -51,175 +45,83 @@ width: 24px; height: 24px; position: relative; - display: grid; - grid-template-columns: 1fr 1fr; + display: flex; + flex-wrap: wrap; gap: 2px; padding: 2px; - background: #f1f5f9; + background: var(--bg-hover); border-radius: 6px; span { + width: 8px; + height: 8px; border-radius: 2px; - background: #1353a0; + background: var(--primary-main); + opacity: 0.8; - &:nth-child(2) { opacity: 0.7; } - &:nth-child(3) { opacity: 0.4; } - &:nth-child(4) { opacity: 0.2; } + &:nth-child(2) { + opacity: 0.6; + } + + &:nth-child(3) { + opacity: 0.4; + } + + &:nth-child(4) { + opacity: 0.2; + } } } } .table-count { - padding: 0.2rem 0.65rem; - background: #f1f5f9; - color: #64748b; - border-radius: 12px; + padding: 0.25rem 0.75rem; + background: var(--bg-hover); + color: var(--text-muted); + border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 500; - font-family: 'Roboto Mono', monospace; + letter-spacing: 0.02em; } } - /* 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; - .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; - } + mat-form-field { + width: 240px; + margin-bottom: -1.25em; } } } .table-container { - border: 1px solid #e2e8f0; - border-radius: 12px; - overflow: hidden; - background: #ffffff; + position: relative; + overflow: auto; + border-radius: 0 0 var(--radius-xl) var(--radius-xl); .table-loader { - height: 2px; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; } } .modern-table { width: 100%; - border-collapse: separate; - border-spacing: 0; + background: transparent; - ::ng-deep { - .mat-mdc-header-row { - background: #f8fafc; - min-height: 38px; - height: 38px; - } + tr.mat-row { + cursor: pointer; + transition: background-color 0.2s ease; - .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; + &:hover { + background-color: var(--bg-hover); } } } - -mat-paginator { - border-top: 1px solid #e2e8f0; - background: #ffffff; -} 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 deleted file mode 100644 index 80264c9de..000000000 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html +++ /dev/null @@ -1,210 +0,0 @@ - - -
- -
-
- - {{ '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 deleted file mode 100644 index cfe41963e..000000000 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss +++ /dev/null @@ -1,462 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -// ─── Variables ────────────────────────────────────────────────────────────── -$bank-primary: #1a3a6b; // deep navy -$bank-accent: #c8a951; // gold trim -$debit-color: #c0392b; -$credit-color: #1a7a3f; -$balance-color: #1a3a6b; -$border-color: #d0d7e3; -$row-alt: #f4f7fb; - -// ─── Container ────────────────────────────────────────────────────────────── -.passbook-container { - padding: 16px; - background: #eef1f7; - min-height: 100%; -} - -// ─── Filter Bar ───────────────────────────────────────────────────────────── -.passbook-filter-bar { - margin-bottom: 16px; - background: #fff; - border-radius: 8px; - padding: 12px 16px; - border: 1px solid $border-color; -} - -.passbook-filter-form { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.passbook-date-field { - width: 180px; -} - -.passbook-filter-spacer { - flex: 1; -} - -// ─── Statement Wrapper ─────────────────────────────────────────────────────── -.statement-wrapper { - background: #fff; - border: 1px solid $border-color; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 2px 12px rgb(0 0 0 / 8%); - font-family: 'Segoe UI', Arial, sans-serif; -} - -// ─── Bank Header ───────────────────────────────────────────────────────────── -.bank-header { - background: $bank-primary; - padding: 18px 24px; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 4px solid $bank-accent; -} - -.bank-logo-area { - display: flex; - align-items: center; - gap: 14px; -} - -.bank-logo-circle { - width: 52px; - height: 52px; - border-radius: 50%; - background: $bank-accent; - display: flex; - align-items: center; - justify-content: center; - - mat-icon { - color: $bank-primary; - font-size: 28px; - width: 28px; - height: 28px; - } -} - -.bank-name-block { - .bank-title { - color: #fff; - font-size: 18px; - font-weight: 700; - letter-spacing: 0.3px; - } - - .bank-subtitle { - color: $bank-accent; - font-size: 11px; - font-weight: 500; - letter-spacing: 1.5px; - text-transform: uppercase; - margin-top: 2px; - } -} - -.statement-label { - text-align: right; - - .stmt-label-text { - color: $bank-accent; - font-size: 13px; - font-weight: 700; - letter-spacing: 2px; - text-transform: uppercase; - } - - .stmt-period { - color: rgb(255 255 255 / 75%); - font-size: 11px; - margin-top: 4px; - } -} - -// ─── Account Details Band ──────────────────────────────────────────────────── -.account-details-band { - background: #f0f3f9; - border-bottom: 1px solid $border-color; - padding: 14px 24px; -} - -.account-details-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px 24px; -} - -.detail-block { - .detail-label { - font-size: 9px; - font-weight: 700; - color: #7a8aaa; - text-transform: uppercase; - letter-spacing: 0.8px; - margin-bottom: 3px; - } - - .detail-value { - font-size: 13px; - color: #222; - - &.bold { - font-weight: 600; - } - - &.mono { - font-family: 'Courier New', monospace; - letter-spacing: 1px; - } - - &.status-badge { - display: inline-block; - padding: 1px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - background: #eee; - color: #555; - - &.active { - background: #d4edda; - color: #155724; - } - } - } -} - -// ─── Balance Summary Strip ─────────────────────────────────────────────────── -.balance-summary-strip { - display: flex; - align-items: stretch; - background: $bank-primary; - padding: 0; -} - -.balance-box { - flex: 1; - padding: 12px 20px; - text-align: center; - - .balance-box-label { - font-size: 10px; - font-weight: 600; - color: rgb(255 255 255 / 65%); - text-transform: uppercase; - letter-spacing: 1px; - margin-bottom: 4px; - } - - .balance-box-value { - font-size: 16px; - font-weight: 700; - color: $bank-accent; - } -} - -.balance-divider { - width: 1px; - background: rgb(255 255 255 / 15%); - margin: 8px 0; -} - -// ─── Transactions Table ─────────────────────────────────────────────────────── -.statement-table { - width: 100%; - border-collapse: collapse; - font-size: 12.5px; - - thead tr { - background: #e8edf5; - border-top: 1px solid $border-color; - border-bottom: 2px solid $bank-primary; - } - - thead th { - padding: 9px 10px; - font-size: 11px; - font-weight: 700; - color: $bank-primary; - text-transform: uppercase; - letter-spacing: 0.4px; - white-space: nowrap; - } - - tbody tr { - border-bottom: 1px solid #e8ecf3; - transition: background 0.1s; - - &:nth-child(even) { - background: $row-alt; - } - - &:hover { - background: #dce6f5; - } - } - - tbody td { - padding: 7px 10px; - vertical-align: middle; - color: #333; - } - - tfoot .total-row { - background: #e8edf5; - border-top: 2px solid $bank-primary; - - td { - padding: 9px 10px; - } - } -} - -// ─── Column widths ──────────────────────────────────────────────────────────── -.col-sr { - width: 38px; -} - -.col-date { - width: 100px; - white-space: nowrap; -} - -.col-desc { - min-width: 140px; -} - -.col-ref { - width: 70px; -} - -.col-amount { - width: 100px; -} - -.col-balance { - width: 90px; -} - -// ─── Amount colors ──────────────────────────────────────────────────────────── -.debit-amount { - color: $debit-color; - font-weight: 500; -} - -.credit-amount { - color: $credit-color; - font-weight: 500; -} - -.balance-amount { - color: $balance-color; - font-weight: 600; - font-family: 'Courier New', monospace; -} - -.total-label { - font-size: 12px; - font-weight: 700; - color: $bank-primary; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.closing-balance { - font-size: 14px; - font-weight: 700; - color: $bank-primary; - font-family: 'Courier New', monospace; -} - -// ─── Badges & misc ──────────────────────────────────────────────────────────── -.txn-desc { - font-weight: 500; -} - -.text-right { - text-align: right; -} - -.text-center { - text-align: center; -} - -.text-muted { - color: #999; -} - -.mono { - font-family: 'Courier New', monospace; - font-size: 11px; -} - -// ─── No Data ────────────────────────────────────────────────────────────────── -.no-data-block { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px; - color: #aaa; - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - margin-bottom: 12px; - } - - p { - font-size: 14px; - margin: 0; - } -} - -// ─── Statement Footer ───────────────────────────────────────────────────────── -.statement-footer { - background: #f0f3f9; - border-top: 1px solid $border-color; - padding: 12px 24px; - text-align: center; - - .footer-note { - font-size: 10px; - color: #888; - margin-bottom: 4px; - font-style: italic; - } - - .footer-meta { - font-size: 10px; - color: #aaa; - letter-spacing: 0.3px; - } -} - -// ─── Responsive ─────────────────────────────────────────────────────────────── -@media (width <= 768px) { - .account-details-grid { - grid-template-columns: 1fr 1fr; - } - - .bank-header { - flex-direction: column; - gap: 12px; - align-items: flex-start; - } - - .statement-label { - text-align: left; - } - - .balance-summary-strip { - flex-direction: column; - } - - .balance-divider { - width: auto; - height: 1px; - margin: 0 20px; - } - - .passbook-filter-form { - flex-direction: column; - align-items: stretch; - } - - .passbook-date-field { - width: 100%; - } - - .passbook-filter-spacer { - display: none; - } -} - -@media (width <= 480px) { - .account-details-grid { - grid-template-columns: 1fr; - } -} - -// ─── Print ──────────────────────────────────────────────────────────────────── -@media print { - .no-print { - display: none !important; - } - - .passbook-container { - background: #fff; - padding: 0; - } - - .statement-wrapper { - border: none; - box-shadow: none; - border-radius: 0; - } - - .bank-header { - -webkit-print-color-adjust: exact; - print-color-adjust: exact; - } - - .balance-summary-strip { - -webkit-print-color-adjust: exact; - print-color-adjust: exact; - } -} 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 deleted file mode 100644 index 4b882635f..000000000 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -/** Angular Imports */ -import { Component, OnInit, ElementRef, ViewChild, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { UntypedFormBuilder, Validators } from '@angular/forms'; -import { CurrencyPipe } from '@angular/common'; -import { MatIcon } from '@angular/material/icon'; -import { DateFormatPipe } from '../../../pipes/date-format.pipe'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; - -/** - * Share Account Passbook β€” a printable, chronological (oldest-first) ledger of every - * Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings: - * shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution - * Number / Date of Meeting on redeem rows. - */ -@Component({ - selector: 'mifosx-shares-passbook-tab', - templateUrl: './passbook-tab.component.html', - styleUrls: ['./passbook-tab.component.scss'], - standalone: true, - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatIcon, - DateFormatPipe, - CurrencyPipe - ] -}) -export class SharesPassbookTabComponent implements OnInit { - private route = inject(ActivatedRoute); - private formBuilder = inject(UntypedFormBuilder); - - @ViewChild('passbookContent') passbookContent: ElementRef; - - /** Account data */ - accountNo = ''; - clientName = ''; - productName = ''; - currency: any = null; - status = ''; - activatedOnDate: any = null; - resolutionNumber = ''; - savingsAccountNumber = ''; - totalApprovedShares = 0; - currentMarketPrice = 0; - - /** Transactions */ - allTransactions: any[] = []; - filteredTransactions: any[] = []; - - /** Date range form */ - minDate = new Date(2000, 0, 1); - maxDate = new Date(); - dateRangeForm: any; - isFiltered = false; - - get currentValue(): number { - return this.totalApprovedShares * this.currentMarketPrice; - } - - ngOnInit(): void { - this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { - const account = data.sharesAccountData; - this.accountNo = account.accountNo || ''; - this.clientName = account.clientName || ''; - this.productName = account.productName || ''; - this.currency = account.currency || null; - this.status = account.status?.value || ''; - this.activatedOnDate = account.timeline?.activatedDate || null; - this.resolutionNumber = account.resolutionNumber || ''; - this.savingsAccountNumber = account.savingsAccountNumber || ''; - this.totalApprovedShares = account.summary?.totalApprovedShares || 0; - this.currentMarketPrice = account.currentMarketPrice || 0; - - const transactions = (account.purchasedShares || []).filter( - (transaction: any) => transaction.type.value !== 'Charge Payment' - ); - this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions)); - this.filteredTransactions = [...this.allTransactions]; - }); - - this.dateRangeForm = this.formBuilder.group({ - fromDate: [ - '', - Validators.required - ], - toDate: [ - '', - Validators.required - ] - }); - } - - isRedeem(transaction: any): boolean { - return transaction.type?.value === 'Redeem'; - } - - filterTransactions(): void { - const fromDate = this.dateRangeForm.value.fromDate; - const toDate = this.dateRangeForm.value.toDate; - - if (!fromDate || !toDate) return; - - const from = new Date(fromDate).getTime(); - const to = new Date(toDate).getTime(); - - this.filteredTransactions = this.allTransactions.filter((txn: any) => { - const txnDate = this.getTransactionDateAsTime(txn.purchasedDate); - return txnDate >= from && txnDate <= to; - }); - this.isFiltered = true; - } - - clearFilter(): void { - this.dateRangeForm.reset(); - this.filteredTransactions = [...this.allTransactions]; - this.isFiltered = false; - } - - private getTransactionDateAsTime(date: any): number { - if (Array.isArray(date) && date.length >= 3) { - return new Date(date[0], date[1] - 1, date[2]).getTime(); - } - return new Date(date).getTime(); - } - - printPassbook(): void { - const content = this.passbookContent?.nativeElement?.innerHTML; - if (!content) return; - - const printWindow = window.open('', 'SharePassbook', 'height=700,width=900'); - printWindow.document.write(` - - - 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 4402b9160..9761aeeef 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,16 +145,6 @@ > {{ '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 769d76d05..f3941f52b 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,25 +54,6 @@ - - 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 46a7e3db0..c28640324 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,18 +56,14 @@ export class TransactionsTabComponent implements OnInit { transactionsData: any; /** Data source for transactions table. */ dataSource: MatTableDataSource; - /** 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. */ + /** Columns to be displayed in transactions table. */ displayedColumns: string[] = [ 'transactionDate', 'transactionType', 'totalShares', 'purchasedOrRedeemedPrice', 'chargeAmount', - 'amountRecievedOrReturned', - 'runningBalance', - 'resolutionMeeting' + 'amountRecievedOrReturned' ]; /** @@ -77,19 +73,9 @@ 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') - .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(); + this.transactionsData = this.shareAccountData.purchasedShares.filter( + (transaction: any) => transaction.type.value !== 'Charge Payment' + ); }); } diff --git a/src/app/shares/shares-routing.module.ts b/src/app/shares/shares-routing.module.ts index d12979e83..4c5193aef 100644 --- a/src/app/shares/shares-routing.module.ts +++ b/src/app/shares/shares-routing.module.ts @@ -26,7 +26,6 @@ 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 = [ @@ -76,11 +75,6 @@ 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/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 deleted file mode 100644 index 3f107ddac..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.html +++ /dev/null @@ -1,77 +0,0 @@ - - -

- βœ‰ - 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 deleted file mode 100644 index 7663df382..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.scss +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -:host { - display: block; -} - -.ced-title { - display: flex; - align-items: center; - gap: 10px; - font-size: 1.15rem; - font-weight: 600; - color: #1a237e; - position: relative; /* For aligning badges to the right */ - - &__icon { - font-size: 1.3rem; - } -} - -.ced-content { - min-width: 560px; - max-width: 760px; - max-height: 72vh; - overflow-y: auto; - padding: 0 4px; -} - -/* ── Gmail-Style Email Header ─────────────────────────────────────────────────── */ -.ced-gmail-header { - display: flex; - flex-direction: column; - margin-bottom: 30px; - padding: 0 4px; - font-family: Roboto, Inter, sans-serif; /* Gmail-like typography */ -} - -.ced-gmail-subject { - font-size: 1.35rem; - font-weight: 400; - color: #202124; - margin: 0 0 24px; - line-height: 1.4; -} - -.ced-gmail-meta { - display: flex; - align-items: center; /* Avatar vertically centered with text */ - gap: 12px; -} - -.ced-gmail-avatar { - width: 40px; - height: 40px; - border-radius: 50%; - background-color: #f06292; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.ced-gmail-avatar-letter { - color: white; - font-size: 1.25rem; - font-weight: 500; -} - -.ced-gmail-meta-content { - display: flex; - flex-direction: column; - flex-grow: 1; - justify-content: center; - gap: 2px; -} - -.ced-gmail-meta-top { - display: flex; - align-items: baseline; - width: 100%; -} - -.ced-gmail-spacer { - flex-grow: 1; -} - -.ced-gmail-sender-name { - font-size: 0.95rem; - font-weight: 600; - color: #202124; - margin-right: 6px; -} - -.ced-gmail-sender-email { - color: #5f6368; - font-size: 0.8rem; -} - -.ced-gmail-date { - color: #5f6368; - font-size: 0.75rem; - white-space: nowrap; -} - -.ced-gmail-meta-bottom { - display: flex; - align-items: center; - gap: 4px; -} - -.ced-gmail-to { - font-size: 0.75rem; - color: #5f6368; -} - -.ced-gmail-caret { - font-size: 0.75rem; - color: #5f6368; - cursor: pointer; -} - -.ced-status-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-left: auto; /* Pushes badges to the right in the flex container */ -} - -/* ── Status chips ───────────────────────────────────────────────────────── */ -.ced-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 12px 4px 10px; - border-radius: 20px; - font-size: 0.82rem; - font-weight: 500; - - &__dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; - } - - &__icon { - font-size: 0.82rem; - } - - &--sent { - background: #e8f5e9; - color: #1b5e20; - - .ced-chip__dot { - background: #43a047; - } - } - - &--notsent { - background: #f5f5f5; - color: #757575; - - .ced-chip__dot { - background: #bdbdbd; - } - } - - &--seen { - background: #e3f2fd; - color: #0d47a1; - } - - &--notseen { - background: #f5f5f5; - color: #9e9e9e; - } -} - -/* ── Divider ────────────────────────────────────────────────────────────── */ -.ced-divider { - border: none; - border-top: 1px solid #eee; - margin: 12px 0; -} - -/* ── Email body iframe-like container ───────────────────────────────────── */ -.ced-email-body { - background: #fafafa; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 20px 24px; - font-family: Arial, sans-serif; - font-size: 0.93rem; - line-height: 1.6; - color: #333; - - // Reset any Angular Material overrides inside the rendered HTML - table { - border-collapse: collapse; - width: 100%; - } - - td, - th { - padding: 6px 12px; - border: 1px solid #e0e0e0; - } - - p { - margin: 0 0 10px; - } - - ul { - padding-left: 20px; - } -} - -/* ── Empty state ────────────────────────────────────────────────────────── */ -.ced-empty { - text-align: center; - padding: 32px 0; - color: #9e9e9e; - - &__hint { - font-size: 0.85rem; - margin-top: 6px; - } -} 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 deleted file mode 100644 index 5954b11f9..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-client-edits/client-edit-email-dialog/client-edit-email-dialog.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, inject } from '@angular/core'; -import { - MAT_DIALOG_DATA, - MatDialogTitle, - MatDialogContent, - MatDialogActions, - MatDialogClose -} from '@angular/material/dialog'; -import { CdkScrollable } from '@angular/cdk/scrolling'; -import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; - -export interface ClientEditEmailDialogData { - emailSubject: string | null; - emailBody: string | null; - emailSent: boolean; - emailSentAt: string | null; - emailOpenedAt: string | null; - emailFrom: string | null; - emailTo: string | null; -} - -/** Displays the exact confirmation email sent to the client when their pending edit was approved. - * Shows subject, HTML body (rendered), sent timestamp, and read/seen status. */ -@Component({ - selector: 'mifosx-client-edit-email-dialog', - templateUrl: './client-edit-email-dialog.component.html', - styleUrls: ['./client-edit-email-dialog.component.scss'], - standalone: true, - imports: [ - ...STANDALONE_SHARED_IMPORTS, - MatDialogTitle, - CdkScrollable, - MatDialogContent, - MatDialogActions, - MatDialogClose, - FaIconComponent - ] -}) -export class ClientEditEmailDialogComponent { - data = inject(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 4942b051f..98f557aed 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,29 +126,6 @@ Actions - - - - {{ edit.emailSent ? 'Sent' : 'Not Sent' }} - - @if (edit.emailSent) { - - - {{ edit.emailOpenedAt ? 'Seen' : 'Not Seen' }} - - } - - @@ -160,16 +137,6 @@ 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 642138ffd..61ffa8119 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,45 +346,3 @@ $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 40ba63a9d..c3fdc1c3b 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,7 +34,6 @@ 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 @@ -98,8 +97,7 @@ const NATIONALITY_LABELS: Record = { MatRowDef, MatRow, MatIcon, - MatPaginatorModule, - ClientEditEmailDialogComponent + MatPaginatorModule ] }) export class PendingClientEditsComponent implements OnInit { @@ -425,52 +423,6 @@ export class PendingClientEditsComponent implements OnInit { }); } - /** Opens the email preview dialog for an edit row. Fetches the email content first (subject, - * body, sent/opened timestamps) via the email-preview endpoint, then opens the dialog. */ - viewEmail(edit: any): void { - this.clientsService.getClientEditEmailPreview(edit.clientId, edit.id).subscribe({ - next: (preview) => { - try { - this.dialog.open(ClientEditEmailDialogComponent, { - width: '800px', - maxWidth: '96vw', - data: { - emailSubject: preview?.emailSubject || null, - emailBody: preview?.emailBody || null, - emailSent: preview?.emailSent || false, - emailSentAt: preview?.emailSentAt || null, - emailOpenedAt: preview?.emailOpenedAt || null, - emailFrom: preview?.emailFrom || null, - emailTo: preview?.emailTo || null - } - }); - } catch (e: any) { - console.error('Dialog open failed (next): ', e); - } - }, - error: () => { - try { - // Fallback: open dialog with no-content state - this.dialog.open(ClientEditEmailDialogComponent, { - width: '800px', - maxWidth: '96vw', - data: { - emailSubject: null, - emailBody: null, - emailSent: false, - emailSentAt: null, - emailOpenedAt: null, - emailFrom: null, - emailTo: null - } - }); - } catch (e: any) { - console.error('Dialog open failed (error): ', e); - } - } - }); - } - reload(): void { this.load(); } 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 deleted file mode 100644 index 347691d98..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.html +++ /dev/null @@ -1,86 +0,0 @@ - - -
- -
- - -
- - - - - - - - - - - - - - - - - @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 deleted file mode 100644 index e3b249c01..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.scss +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -@keyframes swr-list-popup-fade { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes swr-list-popup-zoom { - from { - opacity: 0; - transform: scale(0.97) translateY(10px); - } - - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -.swr-list-popup-backdrop { - position: fixed; - inset: 0; - // No backdrop-filter here: filter/backdrop-filter on an ancestor makes browsers treat it as a new - // containing block for `position: fixed` descendants β€” the embedded review screen inside this popup - // renders its OWN fixed-position dialogs (Validate, Reject, Confirm-Submit), which would then be - // confined to this backdrop's box instead of the real viewport. Extra opacity on the background - // color compensates for the loss of the blur look. - background: rgb(15 25 45 / 65%); - z-index: 1100; - display: flex; - align-items: flex-start; - justify-content: center; - padding: 32px 20px; - overflow-y: auto; - animation: swr-list-popup-fade 0.2s ease-out; -} - -.swr-list-popup-panel { - background: #f7f9fc; - border-radius: 16px; - width: 100%; - max-width: 1100px; - box-shadow: 0 24px 60px rgb(0 0 0 / 25%); - animation: swr-list-popup-zoom 0.25s ease-out; - padding: 4px; -} - -@media (prefers-reduced-motion: reduce) { - .swr-list-popup-backdrop, - .swr-list-popup-panel { - animation: none; - } -} 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 deleted file mode 100644 index 3c21d21ad..000000000 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; - -import { ClerkShareWithdrawalService } from '../../../cashier/clerk-share-withdrawal.service'; -import { ShareWithdrawalRequestComponent } from '../../../clerk/share-withdrawal-request/share-withdrawal-request.component'; - -/** Approval Center β€” Share Withdrawal Requests tab. A Bank Manager (checker) reviews, approves - * or rejects share withdrawal requests submitted from the Clerk Dashboard. Mirrors - * WithdrawalRequestsComponent (cash withdrawal), adapted for shares. Clicking "Review" opens the - * review screen in a popup on top of this list, rather than navigating away, so approving/rejecting - * drops the manager straight back into the (now refreshed) list. */ -@Component({ - selector: 'mifosx-share-withdrawal-requests', - templateUrl: './share-withdrawal-requests.component.html', - styleUrls: [ - '../../../cashier/cashier-shared.scss', - '../../../clerk/clerk-shared.scss', - './share-withdrawal-requests.component.scss' - ], - standalone: true, - imports: [ - CommonModule, - FormsModule, - ShareWithdrawalRequestComponent - ] -}) -export class ShareWithdrawalRequestsComponent implements OnInit { - private service = inject(ClerkShareWithdrawalService); - - requests: any[] = []; - totalRecords = 0; - loading = false; - loaded = false; - - filterStatus = 'PENDING'; - - showReviewModal = false; - reviewRequestId: number | null = null; - - get statusOptions(): { value: string; label: string }[] { - return [ - { value: 'PENDING', label: 'Pending' }, - { value: 'APPROVED', label: 'Approved' }, - { value: 'REJECTED', label: 'Rejected' } - ]; - } - - ngOnInit(): void { - this.load(); - } - - load(): void { - this.loading = true; - this.service - .getPendingRequests(this.filterStatus, 0, 50) - .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) - .subscribe((res: any) => { - this.requests = res?.pageItems ?? []; - this.totalRecords = Number(res?.totalFilteredRecords ?? 0); - this.loading = false; - this.loaded = true; - }); - } - - onFilterChange(): void { - this.load(); - } - - review(req: any): void { - this.reviewRequestId = req.id; - this.showReviewModal = true; - } - - closeReviewModal(): void { - this.showReviewModal = false; - this.reviewRequestId = null; - } - - onReviewActionCompleted(): void { - this.closeReviewModal(); - this.load(); - } - - statusClass(status: string): string { - const map: Record = { - 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 af2a9cd43..e9a8440d0 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 @@ -20,207 +20,143 @@
- -