diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index fcfb63214..c8c184925 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -62,6 +62,38 @@ 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: 'share-closure-request', + loadComponent: () => + import('../clerk/share-closure-request/share-closure-request.component').then( + (m) => m.ShareClosureRequestComponent + ), + data: { title: 'New Share Closure Request', breadcrumb: 'New Share Closure Request', mode: 'create' } + }, + { + path: 'share-closure-requests', + loadComponent: () => + import('../clerk/my-share-closure-requests/my-share-closure-requests.component').then( + (m) => m.MyShareClosureRequestsComponent + ), + data: { title: 'My Share Closure Requests', breadcrumb: 'My Share Closure Requests' } + }, { path: 'account-facilities', loadComponent: () => diff --git a/src/app/cashier/clerk-share-closure.service.ts b/src/app/cashier/clerk-share-closure.service.ts new file mode 100644 index 000000000..ca57131b8 --- /dev/null +++ b/src/app/cashier/clerk-share-closure.service.ts @@ -0,0 +1,53 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** HTTP service for the Share Closure maker-checker workflow — mirrors + * ClerkShareWithdrawalService, adapted for closing an already-zero-share account. */ +@Injectable({ providedIn: 'root' }) +export class ClerkShareClosureService { + private http = inject(HttpClient); + + submit(data: any): Observable { + return this.http.post('/clerk/share-closure-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-closure-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-closure-requests', { params }); + } + + getById(id: number): Observable { + return this.http.get(`/clerk/share-closure-requests/${id}`); + } + + validate(id: number): Observable { + return this.http.post(`/clerk/share-closure-requests/${id}/validate`, {}); + } + + approve(id: number): Observable { + return this.http.post(`/clerk/share-closure-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + return this.http.post(`/clerk/share-closure-requests/${id}/reject`, { reason }); + } +} diff --git a/src/app/cashier/clerk-share-withdrawal.service.ts b/src/app/cashier/clerk-share-withdrawal.service.ts new file mode 100644 index 000000000..fde90ad02 --- /dev/null +++ b/src/app/cashier/clerk-share-withdrawal.service.ts @@ -0,0 +1,53 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors + * ClerkWithdrawalService (cash withdrawal), adapted for shares. */ +@Injectable({ providedIn: 'root' }) +export class ClerkShareWithdrawalService { + private http = inject(HttpClient); + + submit(data: any): Observable { + return this.http.post('/clerk/share-withdrawal-requests', data); + } + + getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit)); + if (status) params = params.set('status', status); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get('/clerk/share-withdrawal-requests/my', { params }); + } + + getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable { + let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit)); + if (fromDate) params = params.set('fromDate', fromDate); + if (toDate) params = params.set('toDate', toDate); + return this.http.get('/clerk/share-withdrawal-requests', { params }); + } + + getById(id: number): Observable { + return this.http.get(`/clerk/share-withdrawal-requests/${id}`); + } + + validate(id: number): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {}); + } + + approve(id: number): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {}); + } + + reject(id: number, reason: string): Observable { + return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason }); + } +} diff --git a/src/app/clerk/clerk-shared.scss b/src/app/clerk/clerk-shared.scss new file mode 100644 index 000000000..0695f3600 --- /dev/null +++ b/src/app/clerk/clerk-shared.scss @@ -0,0 +1,77 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens — + * self-contained (this branch has no separate "investment" module to borrow them from). */ + +.inv-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.inv-form-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; + font-weight: 600; + color: #64748b; + + input, + select, + textarea { + height: 36px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + box-sizing: border-box; + } + + textarea { + height: auto; + padding: 8px 10px; + resize: vertical; + } +} + +.inv-form-actions { + display: flex; + gap: 10px; + padding: 12px 16px 16px; +} + +.inv-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--active { + background: #fef3c7; + color: #92400e; + } + + &--matured { + background: #dcfce7; + color: #166534; + } + + &--written_off { + background: #fee2e2; + color: #991b1b; + } +} diff --git a/src/app/clerk/my-share-closure-requests/my-share-closure-requests.component.html b/src/app/clerk/my-share-closure-requests/my-share-closure-requests.component.html new file mode 100644 index 000000000..01f19ed07 --- /dev/null +++ b/src/app/clerk/my-share-closure-requests/my-share-closure-requests.component.html @@ -0,0 +1,69 @@ + + +
+
+
+
CLERK · SHARE CLOSURE
+

My Share Closure Requests

+

Share account closure 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 NoClientClosed DateSubmitted AtStatus
Loading…
No share closure requests found.
+ {{ r.account_no }} + {{ r.client_name }}{{ formatDateTime(r.closed_date) }}{{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} +
+
+
diff --git a/src/app/clerk/my-share-closure-requests/my-share-closure-requests.component.ts b/src/app/clerk/my-share-closure-requests/my-share-closure-requests.component.ts new file mode 100644 index 000000000..302e95b54 --- /dev/null +++ b/src/app/clerk/my-share-closure-requests/my-share-closure-requests.component.ts @@ -0,0 +1,112 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service'; + +/** Clerk Dashboard "My Share Closure Requests" — lists the share account closure requests this + * clerk has submitted and their approval status. Mirrors MyShareWithdrawalRequestsComponent. */ +@Component({ + selector: 'mifosx-my-share-closure-requests', + templateUrl: './my-share-closure-requests.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../clerk-shared.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule + ] +}) +export class MyShareClosureRequestsComponent implements OnInit { + private service = inject(ClerkShareClosureService); + 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-closure-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/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html new file mode 100644 index 000000000..9d57ea8f4 --- /dev/null +++ b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.html @@ -0,0 +1,73 @@ + + +
+
+
+
CLERK · SHARE WITHDRAWAL
+

My Share Requests

+

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

+
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + @if (loading) { + + + + } @else if (loaded && requests.length === 0) { + + + + } @else { + @for (r of requests; track r.id) { + + + + + + + + + + } + } + +
Account NoClientSharesResolution No.Meeting DateSubmitted AtStatus
Loading…
No share withdrawal requests found.
+ {{ r.account_no }} + {{ r.client_name }}{{ r.requested_shares }}{{ r.resolution_number }}{{ formatDateTime(r.meeting_date) }}{{ formatDateTime(r.submitted_at) }} + {{ statusLabel(r.status) }} +
+
+
diff --git a/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts new file mode 100644 index 000000000..3d19c8b57 --- /dev/null +++ b/src/app/clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component.ts @@ -0,0 +1,112 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; + +import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service'; + +/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has + * submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */ +@Component({ + selector: 'mifosx-my-share-withdrawal-requests', + templateUrl: './my-share-withdrawal-requests.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../clerk-shared.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule + ] +}) +export class MyShareWithdrawalRequestsComponent implements OnInit { + private service = inject(ClerkShareWithdrawalService); + private router = inject(Router); + + requests: any[] = []; + totalRecords = 0; + loading = false; + loaded = false; + + filterStatus = ''; + + get statusOptions(): { value: string; label: string }[] { + return [ + { value: '', label: 'All' }, + { value: 'PENDING', label: 'Pending' }, + { value: 'APPROVED', label: 'Approved' }, + { value: 'REJECTED', label: 'Rejected' } + ]; + } + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.service + .getMyRequests(this.filterStatus || undefined, 0, 50) + .pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 }))) + .subscribe((res: any) => { + this.requests = res?.pageItems ?? []; + this.totalRecords = Number(res?.totalFilteredRecords ?? 0); + this.loading = false; + this.loaded = true; + }); + } + + onFilterChange(): void { + this.load(); + } + + newRequest(): void { + this.router.navigate(['/clerk/share-withdrawal-request']); + } + + statusClass(status: string): string { + const map: Record = { + PENDING: 'inv-badge--active', + APPROVED: 'inv-badge--matured', + REJECTED: 'inv-badge--written_off' + }; + return `inv-badge ${map[status] ?? ''}`; + } + + statusLabel(status: string): string { + const map: Record = { + PENDING: 'Pending Approval', + APPROVED: 'Approved', + REJECTED: 'Rejected' + }; + return map[status] ?? status; + } + + formatDateTime(val: any): string { + if (!val) return '—'; + try { + const d = new Date(val); + return d.toLocaleString('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return String(val); + } + } +} diff --git a/src/app/clerk/share-closure-request/share-closure-request.component.html b/src/app/clerk/share-closure-request/share-closure-request.component.html new file mode 100644 index 000000000..898b9f092 --- /dev/null +++ b/src/app/clerk/share-closure-request/share-closure-request.component.html @@ -0,0 +1,355 @@ + + +
+
+
+
{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE CLOSURE' }}
+

{{ pageTitle }}

+

{{ pageSubtitle }}

+
+
+ +
+
+ +
+ +
+ @if (!isReview) { + + } + @if (account) { + + + + } + @if (loadingAccount) { +
Loading account…
+ } + @if (account && heldShares !== 0 && !isReview) { +
+ This account still holds {{ heldShares }} share(s) — all shares must be withdrawn first via the Share + Withdrawal workflow before it becomes eligible for closure. +
+ } +
+
+ + @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 shares held, account status, active loan/guarantee and client compliance before you approve. +
+
+ +
+ +
+ + +
+
+ } + + @if (showValidateModal) { +
+
+
+
+ fact_check + Validate Share Closure Request +
+ +
+ +
+

+ Re-checking closure of {{ account?.accountNo }} ({{ account?.clientName }}) against the + live account, loan/guarantee and compliance state — 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 && 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 Closure Request

+

Review the details below before submitting for Bank Manager approval.

+ + + + + + + + + + + + + + + + + + + +
Account No / Client{{ account?.accountNo }} — {{ account?.clientName }}
Shares Held{{ heldShares }}
Closed Date{{ closedDate }}
Note{{ note || '—' }}
+ + + + + @for (check of verificationChecks; track check.label) { + + + + + } + +
{{ check.label }} + {{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }} +
+ +
+ + +
+
+
+ } + + @if (showRejectDialog) { +
+
+

Reject Share Closure Request

+ +
+ + +
+
+
+ } +
diff --git a/src/app/clerk/share-closure-request/share-closure-request.component.ts b/src/app/clerk/share-closure-request/share-closure-request.component.ts new file mode 100644 index 000000000..afa98ee11 --- /dev/null +++ b/src/app/clerk/share-closure-request/share-closure-request.component.ts @@ -0,0 +1,336 @@ +/** + * 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 { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service'; +import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component'; + +export type ShareClosureMode = 'create' | 'review'; + +interface ActivityEntry { + timestamp: string; + action: string; + user: string; + note?: string; +} + +/** + * Share Account Closure request — maker screen in the Clerk Dashboard. A clerk finds a member's + * Share Account that already holds zero shares (all shares already withdrawn), enters the closed + * date plus an optional note, and submits for Bank Manager approval. This is the ONLY entry point + * a share account closure should go through — approval server-side executes the real Fineract + * "close" command (see ClerkShareClosureRequestApiResource on the backend). + */ +@Component({ + selector: 'mifosx-share-closure-request', + templateUrl: './share-closure-request.component.html', + styleUrls: [ + '../../cashier/cashier-shared.scss', + '../clerk-shared.scss', + '../share-withdrawal-request/share-withdrawal-request.component.scss' + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + RouterModule, + MatIconModule, + MatSnackBarModule, + SearchSelectComponent + ] +}) +export class ShareClosureRequestComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private sharesService = inject(SharesService); + private closureService = inject(ClerkShareClosureService); + 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: ShareClosureMode | null = null; + @Input() overrideRequestId: number | null = null; + @Output() closeRequested = new EventEmitter(); + @Output() actionCompleted = new EventEmitter(); + + mode: ShareClosureMode = 'create'; + requestId: number | null = null; + + submitting = false; + loadingAccount = false; + + selectedAccountId = ''; + accountOptions: SearchSelectOption[] = []; + + account: any = null; + + closedDate = ''; + note = ''; + + 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 heldShares(): number { + return Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0); + } + + get isFormValid(): boolean { + if (!this.account) return false; + if (this.heldShares !== 0) return false; + if (!this.closedDate) 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 }[] { + return [ + { label: 'No shares currently held', pass: this.heldShares === 0, detail: `${this.heldShares} share(s) held` }, + { label: 'Closed Date provided', pass: !!this.closedDate, detail: this.closedDate || 'Missing' } + ]; + } + + get isReview(): boolean { + return this.mode === 'review'; + } + + get pageTitle(): string { + return this.isReview ? 'Review Share Closure Request' : 'New Share Closure Request'; + } + + get pageSubtitle(): string { + return this.isReview + ? "Approve or reject the clerk's share account closure request." + : 'Close a Share Account whose shares have already been fully withdrawn, for Bank Manager approval.'; + } + + ngOnInit(): void { + this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareClosureMode) ?? 'create'; + const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id']; + this.requestId = idParam ? Number(idParam) : null; + + const today = new Date(); + this.closedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + this.sharesService + .getShareAccountSummaries() + .pipe(catchError(() => of([]))) + .subscribe((rows: any[]) => { + this.accountOptions = (rows || []) + .filter((r) => (r.status?.value || '').toLowerCase() === 'active' && Number(r.totalApprovedShares ?? 0) === 0) + .map((r) => ({ + value: String(r.id), + label: `${r.accountNo} — ${r.clientName} (0 shares held)`, + searchText: `${r.accountNo} ${r.clientName}` + })); + }); + + 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.closureService.getById(id).subscribe({ + next: (req: any) => { + this.reviewRequest = req; + this.closedDate = req.closed_date ? String(req.closed_date).split('T')[0].split(' ')[0] : this.closedDate; + this.note = req.note ?? ''; + + 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 closure 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, + closedDate: this.closedDate, + note: this.note + }; + this.closureService.submit(payload).subscribe({ + next: () => { + this.snackBar.open('Share closure 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 (shares still zero, account still active, no active loan/guarantee, + * compliance standing) rather than relying on data already loaded in the browser — catches drift + * since submission. Results are revealed one step at a time for the pipeline-style animation. */ + runBackendValidation(): void { + if (!this.reviewRequest || this.validating) return; + this.validating = true; + this.backendChecks = null; + this.validateSteps = []; + this.closureService.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.closureService.approve(this.reviewRequest.id).subscribe({ + next: () => { + this.snackBar.open('Share closure approved — account closed.', 'Close', { duration: 6000 }); + if (this.embedded) { + this.actionCompleted.emit(); + } else { + this.router.navigate(['/checker-inbox-and-tasks/share-closure-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.closureService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({ + next: () => { + this.showRejectDialog = false; + this.snackBar.open('Share closure request rejected.', 'Close', { duration: 4000 }); + if (this.embedded) { + this.actionCompleted.emit(); + } else { + this.router.navigate(['/checker-inbox-and-tasks/share-closure-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-closure-requests'] : ['/clerk/dashboard']); + } +} diff --git a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html new file mode 100644 index 000000000..f8c536f33 --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.html @@ -0,0 +1,431 @@ + + +
+
+
+
{{ 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 new file mode 100644 index 000000000..140ccec89 --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.scss @@ -0,0 +1,433 @@ +/** + * 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 new file mode 100644 index 000000000..30ea07971 --- /dev/null +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts @@ -0,0 +1,416 @@ +/** + * 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) => { + let 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.'); + if (res?.autoClosureRequestId) { + message += ' All shares now withdrawn — a Share Closure Request was auto-submitted for manager review.'; + } + this.snackBar.open(message, 'Close', { duration: 8000 }); + 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 new file mode 100644 index 000000000..a681b04e5 --- /dev/null +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.html @@ -0,0 +1,90 @@ +
+
+ people + CHILDREN +
+ + +
+
+ @for (child of children.controls; track $index) { +
+
+ + Child Full Name* + + + + Gender* + + @for (g of genderIdOptions; track g.id) { + {{ g.name }} + } + + + + Date of Birth + + + + + + Nationality + + Indian + NRI + Foreign National + Other + + + + Aadhaar Number (Optional) + + + + PAN Number (Optional) + + @if ( + $any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched + ) { + Invalid PAN format + } + +
+
+ @if ($index < children.controls.length - 1) {
+
+ } + } +
+ +
+
+
+ + + + + +
\ No newline at end of file diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts new file mode 100644 index 000000000..6e457afbb --- /dev/null +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component.ts @@ -0,0 +1,98 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms'; + +import { MatIcon } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'cbs-client-family-member-child-dialog', + templateUrl: './client-family-member-child-dialog.component.html', + styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatIcon, + MatButtonModule, + MatInputModule, + MatSelectModule, + MatDatepickerModule, + MatTooltipModule + ] +}) +export class ClientFamilyMemberChildDialogComponent implements OnInit { + + childrenForm: UntypedFormGroup; + genderIdOptions: any[]; + maxDate: Date = new Date(); + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any, + private formBuilder: UntypedFormBuilder + ) { + this.genderIdOptions = data.genderIdOptions; + + this.childrenForm = this.formBuilder.group({ + children: this.formBuilder.array([]) + }); + } + + ngOnInit() { + if (this.data.children && this.data.children.length > 0) { + this.data.children.forEach((child: any) => { + this.children.push(this.formBuilder.group({ + id: [child.id], + fullName: [child.fullName, Validators.required], + genderId: [child.genderId, Validators.required], + dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''], + nationality: [child.nationality || 'INDIAN'], + aadhaarNumber: [child.aadhaarNumber || ''], + panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)] + })); + }); + } else { + this.addChild(); + } + } + + get children(): UntypedFormArray { + return this.childrenForm.get('children') as UntypedFormArray; + } + + createChildFormGroup(): UntypedFormGroup { + return this.formBuilder.group({ + id: [''], + fullName: ['', Validators.required], + genderId: ['', Validators.required], + dateOfBirth: [''], + nationality: ['INDIAN'], + aadhaarNumber: [''], + panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)] + }); + } + + addChild(): void { + this.children.push(this.createChildFormGroup()); + } + + removeChild(index: number): void { + this.children.removeAt(index); + } + + submit(): void { + if (this.childrenForm.valid) { + this.dialogRef.close(this.children.value); + } + } + +} diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 9d76fa2f7..5bf4365cb 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 @@ -968,6 +968,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover'); } + /** Guards the Monthly/Annual and Income/Turnover auto-sync subscriptions below — set this to + * true while programmatically writing both fields of a pair to avoid a feedback loop. */ + private suppressMonthlyAnnualLink = false; + /** * Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either * one auto-fills the other. Uses { emitEvent: false } on the write side so the two 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 new file mode 100644 index 000000000..1b75a798d --- /dev/null +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.html @@ -0,0 +1,428 @@ + + +
+ @if (loading) { +
+ + Reading the customer's deposit ledger… +
+ } @else if (loadError) { +
+ error_outline +
Could not load the customer's accounts. Try again, or check the deposit service.
+
+ } @else { + +
+
+

Form 121 & TDS on deposit interest

+
+ Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest. +
+
+ +
+ @if (availableFys.length) { + + } +
+ Settlement +
+ schedule + {{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }} +
+
+
+
+ + @if (!schedule) { +
+ info_outline +
+ @if (!dateOfBirth) { + Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal + Data tab. + } @else { + No interest has been credited to this customer's deposits yet. + } +
+
+ } @else { + +
+
+
₹{{ schedule.totalInterest | number: '1.0-0' }}
+
Interest earned (FY {{ schedule.fy }})
+
+
+
₹{{ actualTds | number: '1.0-0' }}
+
TDS deducted
+
+
+
₹{{ schedule.threshold | number: '1.0-0' }}
+
Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})
+
+ @if (thresholdCrossed) { +
+
Crossed
+
TDS applies this year
+
+ } @else { +
+
₹{{ headroom | number: '1.0-0' }}
+
More interest before TDS starts
+
+ } + @if (tdsSaved > 0) { +
+
₹{{ tdsSaved | number: '1.0-0' }}
+
Saved by Form 121
+
+ } +
+ + + @if (variance !== 0) { +
+ warning_amber +
+ Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}. + The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest + comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed. +
+
+ } + + @if (declarationNotAppliedToAccounts) { +
+ warning_amber +
+ A Form 121 declaration is on record for this year, but withholding is still switched on for one or more + deposits. Turn it off on those accounts, or tax will keep being deducted. +
+
+ } + + @if (declarationUselessOnJointAccounts) { +
+ warning_amber +
+ This customer has filed Form 121, but tax on + {{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted + against the first-named holder. Their declaration does not cover those — the primary holder must file their + own. +
+
+ } + + @if (shouldSuggestDeclaration) { +
+ lightbulb_outline +
+ ₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability + for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than + reclaiming it later as a refund. +
+
+ } + + @if (!panWellFormed) { +
+ error_outline +
+ No valid PAN on record, so tax is deducted at the higher section 206AA rate of + {{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN. +
+
+ } + + @for (note of schedule.notes; track note) { +
+ info_outline +
{{ note }}
+
+ } + + +
+
+ assignment_turned_in + Declaration +
+
+
+ Status + + @if (declarationOnRecord) { + On record — {{ formLabel }} + } @else { + Not filed + } + +
+
+ Form & threshold band + {{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY +
+
+ PAN + + {{ pan || '—' }} + @if (pan && !panWellFormed) { + invalid + } + +
+ @if (declarationOnRecord && declarationFy) { +
+ Declared for + FY {{ declarationFy }} +
+ } + @if (declarationOnRecord && !acceptanceDateKnown) { +
+ Acceptance date + + Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change + the figures below. + +
+ } + @if (eligibility) { + @for (reason of eligibility.reasons; track reason) { +
+ Eligibility + {{ reason }} +
+ } + @for (note of eligibility.unassessed; track note) { +
+ Not assessed + {{ note }} +
+ } + } +
+
+ + +
+
+ table_chart + Quarter by quarter +
+
+ + + + + + + + + + + + + @for (line of schedule.lines; track trackLine($index, line)) { + + + + + + + + + } + + + + + + + + + +
QuarterInterestCumulativeLiability to dateTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + threshold crossed + } + @if (line.declarationActive) { + Form 121 active + } + ₹{{ line.interest | number: '1.0-0' }}₹{{ line.cumInterest | number: '1.0-0' }}₹{{ line.cumLiability | number: '1.0-0' }} + @if (line.tds !== 0) { + ₹{{ line.tds | number: '1.0-0' }} + } @else { + — + } + {{ line.dueDate }}
Total + ₹{{ schedule.totalInterest | number: '1.0-0' }} + + ₹{{ schedule.totalTds | number: '1.0-0' }} +
+
+ @if (schedule.shieldedInterest > 0) { +
+ ₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the + declaration. This amount is still reportable in the quarterly {{ statute.statement }} return. +
+ } +
+ + +
+
+ account_balance + Deposits counted towards this customer's TDS +
+ @if (liableAccounts.length) { +
+ + + + + + + + + + + + + @for (a of liableAccounts; track trackAccount($index, a)) { + + + + + + + + + } + +
AccountTypeInterest (FY)TDS deductedWithholdingSettles
+
{{ a.accountNo }}
+
{{ a.productName }}
+
{{ a.kind }}₹{{ a.interest | number: '1.0-0' }}₹{{ a.tdsDeducted | number: '1.0-0' }} + @if (a.withHoldTax) { + On + @if (a.taxGroup) { +
{{ a.taxGroup }}
+ } + } @else { + Off + } +
{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
+
+
+ Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a + whole, not branch by branch. +
+ } @else { +
No fixed or recurring deposits where this customer is the deductee.
+ } +
+ + + @if (jointHolderAccounts.length) { +
+
+ group + Deposits where this customer is a joint holder +
+
+ + + + + + + + + + + @for (a of jointHolderAccounts; track trackAccount($index, a)) { + + + + + + + } + +
AccountTypeInterest (FY)Deducted against
+
{{ a.accountNo }}
+
{{ a.productName }}
+
{{ a.kind }}₹{{ a.interest | number: '1.0-0' }} + First-named holder + @if (a.deducteeClientId) { + (client {{ a.deducteeClientId }}) + } +
+
+
+ Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is + not counted in the figures above and does not use up this customer's threshold. +
+
+ } + + + @if (exemptAccounts.length) { +
+
+ savings + Savings and current accounts +
+
+ + + + + + + + + + + @for (a of exemptAccounts; track trackAccount($index, a)) { + + + + + + + } + +
AccountTypeInterest (FY)TDS
+
{{ a.accountNo }}
+
{{ a.productName }}
+
{{ a.kind }}₹{{ a.interest | number: '1.0-0' }}Not applicable
+
+
+ The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold + — the customer claims the 80TTA or 80TTB deduction on it in their own return instead. +
+
+ } + } + } +
diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.scss b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss new file mode 100644 index 000000000..4f3666cf1 --- /dev/null +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss @@ -0,0 +1,334 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.f121 { + --f121-line: #e5e7eb; + --f121-bg: #f9fafb; + --f121-ink: #111827; + --f121-muted: #6b7280; + --f121-blue: #1d4ed8; + --f121-green: #15803d; + --f121-amber: #b45309; + + padding: 16px; + font-size: 13px; + color: var(--f121-ink); +} + +.f121__loading { + display: flex; + align-items: center; + gap: 12px; + padding: 40px 16px; + color: var(--f121-muted); +} + +/* ── Header ── */ +.f121__head { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 16px; +} + +.f121__title { + margin: 0; + font-size: 16px; + font-weight: 700; +} + +.f121__sub { + margin-top: 4px; + color: var(--f121-muted); + font-size: 12px; +} + +.f121__controls { + display: flex; + gap: 16px; + align-items: flex-end; +} + +.f121__field { + display: flex; + flex-direction: column; + gap: 4px; + + > span { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--f121-muted); + } + + select { + padding: 7px 10px; + border: 1px solid var(--f121-line); + border-radius: 6px; + background: #fff; + font-size: 13px; + color: inherit; + } +} + +.f121__mode { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + border: 1px solid var(--f121-line); + border-radius: 6px; + background: var(--f121-bg); + font-weight: 600; + cursor: help; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: var(--f121-muted); + } +} + +/* ── KPI strip ── */ +.f121__kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.f121__kpi { + padding: 12px 14px; + border: 1px solid var(--f121-line); + border-left: 3px solid var(--f121-blue); + border-radius: 8px; + background: #fff; + + &--ok { + border-left-color: var(--f121-green); + } + + &--warn { + border-left-color: var(--f121-amber); + } +} + +.f121__kpi-n { + font-size: 19px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.f121__kpi-l { + margin-top: 2px; + font-size: 11px; + color: var(--f121-muted); + line-height: 1.35; +} + +/* ── Notes ── */ +.f121__note { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 12px; + padding: 11px 13px; + border-radius: 8px; + border-left: 3px solid var(--f121-blue); + background: color-mix(in srgb, var(--f121-blue) 6%, transparent); + line-height: 1.5; + + mat-icon { + flex-shrink: 0; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--f121-blue); + } + + &--warn { + border-left-color: var(--f121-amber); + background: color-mix(in srgb, var(--f121-amber) 8%, transparent); + + mat-icon { + color: var(--f121-amber); + } + } + + &--tip { + border-left-color: var(--f121-green); + background: color-mix(in srgb, var(--f121-green) 7%, transparent); + + mat-icon { + color: var(--f121-green); + } + } +} + +/* ── Cards ── */ +.f121__card { + margin-bottom: 16px; + border: 1px solid var(--f121-line); + border-radius: 10px; + background: #fff; + overflow: hidden; +} + +.f121__card-head { + display: flex; + align-items: center; + gap: 8px; + padding: 11px 14px; + background: var(--f121-bg); + border-bottom: 1px solid var(--f121-line); + font-weight: 700; + font-size: 12.5px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: var(--f121-muted); + } +} + +.f121__foot { + padding: 10px 14px; + border-top: 1px solid var(--f121-line); + background: var(--f121-bg); + color: var(--f121-muted); + font-size: 11.5px; + line-height: 1.5; +} + +.f121__empty { + padding: 20px 14px; + color: var(--f121-muted); + text-align: center; +} + +/* ── Key/value list ── */ +.f121__kv-row { + display: grid; + grid-template-columns: 190px 1fr; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid #f3f4f6; + line-height: 1.5; + + &:last-child { + border-bottom: 0; + } + + > span:first-child { + color: var(--f121-muted); + font-weight: 600; + } + + &--muted > span:last-child { + color: var(--f121-muted); + font-size: 12px; + } + + &--warn > span:last-child { + color: var(--f121-amber); + } +} + +/* ── Tables ── */ +.f121__table-wrap { + overflow-x: auto; +} + +.f121__table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 10px 14px; + background: #fff; + border-bottom: 1px solid var(--f121-line); + color: var(--f121-muted); + font-weight: 700; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + } + + td { + padding: 10px 14px; + border-bottom: 1px solid #f3f4f6; + vertical-align: top; + } + + tr:last-child td { + border-bottom: 0; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .muted { + color: var(--f121-muted); + font-size: 11.5px; + } +} + +.f121__row--warn > td { + background: color-mix(in srgb, var(--f121-amber) 7%, transparent); +} + +.f121__row--total > td { + background: var(--f121-bg); + border-top: 1px solid var(--f121-line); +} + +/* ── Bits ── */ +.f121__pill { + display: inline-flex; + align-items: center; + padding: 2px 9px; + border-radius: 999px; + border: 1px solid var(--f121-line); + background: var(--f121-bg); + color: var(--f121-muted); + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; + + & + & { + margin-left: 5px; + } + + &--ok { + border-color: color-mix(in srgb, var(--f121-green) 35%, transparent); + background: color-mix(in srgb, var(--f121-green) 10%, transparent); + color: var(--f121-green); + } + + &--warn { + border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent); + background: color-mix(in srgb, var(--f121-amber) 12%, transparent); + color: var(--f121-amber); + } +} + +.f121__mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-weight: 600; +} diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts new file mode 100644 index 000000000..e8813db15 --- /dev/null +++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts @@ -0,0 +1,500 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Observable, Subject, forkJoin, of } from 'rxjs'; +import { catchError, switchMap, takeUntil } from 'rxjs/operators'; + +/** Custom Services */ +import { ClientsService } from '../../clients.service'; +import { SavingsService } from 'app/savings/savings.service'; +import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; +import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Domain logic */ +import { + Declaration, + DeclarationForm, + EligibilityResult, + FY_TAX_CONFIG, + InterestCredit, + TdsAccountInput, + TdsMode, + TdsQuarterLine, + TdsSchedule, + ageInFy, + buildSchedule, + checkEligibility, + collectTdsCredits, + declarationFormFor, + formKindFor, + fyOf, + isTdsApplicable, + isWellFormedPan, + normaliseFy, + tdsDeducteeOf, + tdsModeFor +} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute'; + +/** One of the customer's interest-bearing accounts, with its FY figures. */ +export interface Form121AccountRow { + accountNo: string; + productName: string; + /** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */ + kind: string; + /** Interest credited to this account inside the selected FY. */ + interest: number; + /** Tax Fineract has actually withheld on this account inside the FY. */ + tdsDeducted: number; + /** Whether Fineract is currently withholding tax on this account. */ + withHoldTax: boolean; + taxGroup: string | null; + /** When tax on this account settles — from the product, not a preference. */ + mode: TdsMode; + /** Interest here attracts TDS at all (a time deposit, not a savings account). */ + tdsApplicable: boolean; + /** This customer is the deductee — false when they are a joint holder only. */ + isDeductee: boolean; + /** Who the tax is deducted against, when it is not this customer. */ + deducteeClientId: number | null; +} + +/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */ +function toIso(value: unknown): string | null { + if (Array.isArray(value) && value.length >= 3) { + const [ + y, + m, + d + ] = value as number[]; + return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`; + } + if (typeof value === 'string' && value.trim()) { + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) { + return parsed.toISOString().slice(0, 10); + } + } + return null; +} + +@Component({ + selector: 'mifosx-form121-tab', + templateUrl: './form121-tab.component.html', + styleUrls: ['./form121-tab.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + MatIcon, + MatProgressSpinner, + MatTooltip + ] +}) +export class Form121TabComponent implements OnInit, OnDestroy { + private route = inject(ActivatedRoute); + private clientsService = inject(ClientsService); + private savingsService = inject(SavingsService); + private fixedDepositsService = inject(FixedDepositsService); + private recurringDepositsService = inject(RecurringDepositsService); + + private destroy$ = new Subject(); + + clientId = ''; + loading = true; + loadError = false; + + // ── Customer ── + clientName = ''; + dateOfBirth = ''; + pan = ''; + panWellFormed = false; + + // ── Declaration on record ── + declarationOnRecord = false; + /** + * The form actually in force for the selected year. From FY 2026-27 a single + * Form 121 replaced the 15G/15H split, so this is derived from the year rather + * than from the payee's age. + */ + declarationForm: DeclarationForm = '121'; + /** Kind recorded at KYC, meaningful only for a pre-2026 year. */ + declarationKind: '15G' | '15H' = '15G'; + declarationFy = ''; + /** + * The KYC step records only the year, not the date the branch accepted the + * declaration, so it has to be treated as covering the whole year. Surfaced on + * screen rather than hidden — a mid-year acceptance would change the figures. + */ + acceptanceDateKnown = false; + + eligibility: EligibilityResult | null = null; + + // ── Selection ── + availableFys: string[] = []; + fy = ''; + + // ── Computed ── + accounts: Form121AccountRow[] = []; + schedule: TdsSchedule | null = null; + actualTds = 0; + tdsSaved = 0; + + /** Raw account inputs, kept so the year selector can recompute cheaply. */ + private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = []; + /** Withheld-tax postings by FY, from the ledger. */ + private actualTdsByFy = new Map(); + + ngOnInit(): void { + this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => { + this.clientId = params['clientId']; + this.load(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + // ────────────────────────────── Loading ────────────────────────────── + + private load(): void { + this.loading = true; + this.loadError = false; + + forkJoin({ + client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))), + kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))), + accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))) + }) + .pipe( + takeUntil(this.destroy$), + switchMap(({ client, kyc, accounts }) => { + this.applyClient(client); + this.applyKyc(kyc); + const deposits = this.depositAccountsOf(accounts); + return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]); + }), + catchError(() => { + this.loadError = true; + return of([] as any[]); + }) + ) + .subscribe((details: any[]) => { + this.loading = false; + this.ingest(details.filter(Boolean)); + this.recompute(); + }); + } + + private applyClient(client: any): void { + this.clientName = client?.displayName ?? ''; + this.dateOfBirth = toIso(client?.dateOfBirth) ?? ''; + } + + private applyKyc(kyc: any): void { + this.pan = (kyc?.panNumber ?? '').toUpperCase(); + this.panWellFormed = isWellFormedPan(this.pan); + this.declarationOnRecord = !!kyc?.form15Applicable; + this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G'; + this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121'; + this.declarationFy = kyc?.form15FY ?? ''; + this.acceptanceDateKnown = false; + } + + /** Savings, FD and RD accounts — everything that can credit interest. */ + private depositAccountsOf(accounts: any): any[] { + return (accounts?.savingsAccounts ?? []).filter( + (a: any) => a.status?.active || a.status?.matured || a.status?.closed + ); + } + + private fetchAccount(a: any): Observable { + const id = `${a.id}`; + const code = a.depositType?.code ?? ''; + let call: Observable; + if (code.includes('fixedDeposit')) { + call = this.fixedDepositsService.getFixedDepositsAccountData(id); + } else if (code.includes('recurringDeposit')) { + call = this.recurringDepositsService.getRecurringDepositsAccountData(id); + } else { + call = this.savingsService.getSavingsAccountData(id); + } + // One unreadable account must not blank the whole tab. + return call.pipe(catchError(() => of(null))); + } + + /** + * Turns each account payload into an engine input. + * + * Joint and minor attributes are read defensively: they are captured on the + * account stepper but may not surface on every read endpoint, and an absent + * field must default to a single-name account rather than throw. + */ + private ingest(details: any[]): void { + this.accountInputs = []; + this.actualTdsByFy = new Map(); + const fys = new Set(); + const clientIdNum = Number(this.clientId); + + for (const d of details) { + const code: string = d.depositType?.code ?? ''; + const extra = d.extraAttributes ?? d.externalAttributes ?? d; + + const ref = { + id: d.id, + accountNo: d.accountNo ?? `${d.id}`, + depositTypeCode: code, + accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE', + clientId: d.clientId ?? clientIdNum, + primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null, + guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null, + jointHolders: extra.jointHolders ?? d.jointHolders ?? [], + interestPostingPeriodType: d.interestPostingPeriodType ?? null + }; + + const credits: InterestCredit[] = []; + let tdsDeducted = 0; + + for (const t of d.transactions ?? []) { + if (t.reversed || t.isReversal) { + continue; + } + const iso = toIso(t.date); + if (!iso) { + continue; + } + fys.add(fyOf(iso)); + + if (t.transactionType?.interestPosting) { + credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo }); + } else if (t.transactionType?.withholdTax) { + const amount = t.amount ?? 0; + tdsDeducted += amount; + const txnFy = fyOf(iso); + this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount); + } + } + + const deductee = tdsDeducteeOf(ref); + const meta: Form121AccountRow = { + accountNo: ref.accountNo, + productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—', + kind: code.includes('fixedDeposit') + ? 'Fixed Deposit' + : code.includes('recurringDeposit') + ? 'Recurring Deposit' + : 'Savings', + interest: 0, + tdsDeducted, + withHoldTax: !!d.withHoldTax, + taxGroup: d.taxGroup?.name ?? null, + mode: tdsModeFor(ref), + tdsApplicable: isTdsApplicable(ref), + isDeductee: deductee === clientIdNum, + deducteeClientId: deductee + }; + + this.accountInputs.push({ ...ref, credits, meta }); + } + + this.availableFys = [...fys].sort().reverse(); + if (!this.availableFys.includes(this.fy)) { + this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : ''); + } + } + + // ───────────────────────────── Computation ───────────────────────────── + + /** Re-runs the engine for the selected year. */ + recompute(): void { + this.schedule = null; + this.eligibility = null; + this.tdsSaved = 0; + this.accounts = this.accountInputs.map((a) => a.meta); + + if (!this.fy) { + return; + } + + const year = normaliseFy(this.fy); + + // Per-account interest for the selected year, for the three lists. + for (const a of this.accountInputs) { + a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0); + } + this.actualTds = this.actualTdsByFy.get(year) ?? 0; + + if (!this.dateOfBirth) { + return; + } + + // The one place rules 8 to 12 are applied: savings interest dropped, joint + // deposits attributed to the primary holder, every branch aggregated. + const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year); + const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true }; + const declaration = this.declarationForSelectedFy(); + + this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration }); + + if (declaration) { + const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null }); + this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds); + } + + // The branch cannot compute income earned outside the bank, so the income + // limb is reported as unassessed rather than silently passed. + this.eligibility = checkEligibility(ctx, { + estimatedInterest: this.schedule.totalInterest, + estimatedIncome: 0, + incomeOnRecord: false + }); + } + + /** + * When tax settles for this customer overall. + * + * Timing is per account, but the customer needs one view. A quarterly account + * anywhere in the book means a deduction falls due at the first quarter end, so + * that is the honest headline; the per-account list carries the detail. + */ + private get overallMode(): TdsMode { + const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee); + return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL'; + } + + /** The declaration, but only when it was filed for the year being shown. */ + private declarationForSelectedFy(): Declaration | null { + if (!this.declarationOnRecord || !this.fy) { + return null; + } + const year = normaliseFy(this.fy); + if (this.declarationFy && normaliseFy(this.declarationFy) !== year) { + return null; + } + return { + kind: this.declarationKind, + // No acceptance date on record — treated as covering the whole year. + acceptedOn: `${year.slice(0, 4)}-04-01`, + fy: year + }; + } + + // ───────────────────────── Template helpers ───────────────────────── + + get mode(): TdsMode { + return this.schedule?.mode ?? 'QUARTERLY'; + } + + get age(): number { + return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0; + } + + get formKind(): string { + return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—'; + } + + /** The form the payee would file for the selected year. */ + get formLabel(): string { + if (!this.fy) { + return '—'; + } + const form = declarationFormFor(this.fy, this.dateOfBirth); + return form === '121' ? 'Form 121' : `Form ${form}`; + } + + /** Which deduction trigger applies, now that the forms no longer split by age. */ + get bandLabel(): string { + return this.schedule?.senior ? 'Senior citizen' : 'General'; + } + + /** + * Provision and return references for the selected year. Both changed on + * 01 April 2026, so the template reads them rather than hard-coding them. + */ + get statute(): { section: string; statement: string; rule: string; frequency: string } { + const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined; + return { + section: cfg?.interestTdsSection ?? '—', + statement: cfg?.tdsStatementForm ?? '—', + rule: cfg?.declarationRule ?? '—', + frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' + }; + } + + /** Accounts whose interest counts towards this customer's deduction aggregate. */ + get liableAccounts(): Form121AccountRow[] { + return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee); + } + + /** Deposits where this customer is a joint holder but not the deductee. */ + get jointHolderAccounts(): Form121AccountRow[] { + return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee); + } + + /** Savings and current accounts — interest outside the charge on time deposits. */ + get exemptAccounts(): Form121AccountRow[] { + return this.accounts.filter((a) => !a.tdsApplicable); + } + + /** Engine figure less what the ledger shows was actually withheld. */ + get variance(): number { + return (this.schedule?.totalTds ?? 0) - this.actualTds; + } + + /** How much more interest before TDS starts. */ + get headroom(): number { + if (!this.schedule) { + return 0; + } + return Math.max(0, this.schedule.threshold - this.schedule.totalInterest); + } + + get thresholdCrossed(): boolean { + return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold; + } + + /** + * A declaration is on record but deposit accounts are still set to withhold — + * the branch has not switched them off. + */ + get declarationNotAppliedToAccounts(): boolean { + return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax); + } + + /** + * The customer filed a declaration but holds joint deposits where someone else + * is the deductee — their form does nothing for those. + */ + get declarationUselessOnJointAccounts(): boolean { + return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0; + } + + /** Worth prompting the branch to collect a declaration. */ + get shouldSuggestDeclaration(): boolean { + return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0; + } + + trackLine(_: number, line: TdsQuarterLine): number { + return line.quarter; + } + + trackAccount(_: number, account: Form121AccountRow): string { + return account.accountNo; + } +} diff --git a/src/app/clients/kyc-exception.util.spec.ts b/src/app/clients/kyc-exception.util.spec.ts new file mode 100644 index 000000000..1c429ff78 --- /dev/null +++ b/src/app/clients/kyc-exception.util.spec.ts @@ -0,0 +1,175 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { + deriveKycExceptionFields, + KycExceptionInput, + KYC_EXCEPTION_AADHAAR, + KYC_EXCEPTION_PAN +} from './kyc-exception.util'; + +const TODAY = '05 August 2026'; + +/** Individual client, nothing previously recorded — the client-creation case. */ +const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({ + panVerified, + aadhaarVerified, + aadhaarApplicable: true, + hadException: false, + previousReason: null, + todayFormatted: TODAY +}); + +/** Individual client that already carries an open exception. */ +const existingException = ( + panVerified: boolean, + aadhaarVerified: boolean, + previousReason: string +): KycExceptionInput => ({ + panVerified, + aadhaarVerified, + aadhaarApplicable: true, + hadException: true, + previousReason, + todayFormatted: TODAY +}); + +describe('deriveKycExceptionFields', () => { + describe('at client creation', () => { + it('marks both PAN and Aadhaar when neither is entered or verified', () => { + expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({ + isException: true, + exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`, + exceptionResolved: false, + exceptionResolvedOn: null + }); + }); + + it('marks only PAN when Aadhaar verified but PAN did not', () => { + expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({ + isException: true, + exceptionReason: KYC_EXCEPTION_PAN, + exceptionResolved: false, + exceptionResolvedOn: null + }); + }); + + it('marks only Aadhaar when PAN verified but Aadhaar did not', () => { + expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({ + isException: true, + exceptionReason: KYC_EXCEPTION_AADHAAR, + exceptionResolved: false, + exceptionResolvedOn: null + }); + }); + + it('raises nothing when both verify, and does not mark the client resolved', () => { + // A clean client never had an exception, so it must not appear in the Resolved tab either. + expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({ + isException: false, + exceptionReason: null, + exceptionResolved: false, + exceptionResolvedOn: null + }); + }); + + it('does not raise an Aadhaar exception for a non-individual client', () => { + // Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception. + expect( + deriveKycExceptionFields({ + panVerified: true, + aadhaarVerified: false, + aadhaarApplicable: false, + hadException: false, + previousReason: null, + todayFormatted: TODAY + }).isException + ).toBe(false); + }); + }); + + describe('resolving', () => { + it('closes the exception once both checks pass, keeping the reason for the audit trail', () => { + const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; + expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({ + isException: false, + exceptionReason: both, + exceptionResolved: true, + exceptionResolvedOn: TODAY + }); + }); + + it('narrows to the outstanding item when only one of two verifies', () => { + const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; + // PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone. + expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({ + isException: true, + exceptionReason: KYC_EXCEPTION_AADHAAR, + exceptionResolved: false, + exceptionResolvedOn: null + }); + }); + + it('narrows the other way round too', () => { + const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; + expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN); + }); + }); + + describe('re-opening', () => { + it('clears a prior resolution so a new exception returns to Pending', () => { + // Regression: without exceptionResolved=false the record still matched the Resolved query + // and a brand-new exception appeared as already closed. + const result = deriveKycExceptionFields({ + panVerified: false, + aadhaarVerified: true, + aadhaarApplicable: true, + hadException: false, + previousReason: 'PAN Card Verification Failed', + todayFormatted: TODAY + }); + expect(result.exceptionResolved).toBe(false); + expect(result.exceptionResolvedOn).toBeNull(); + expect(result.isException).toBe(true); + }); + }); + + describe('officer-entered reasons', () => { + it('keeps a manual reason alongside the automatic ones', () => { + const result = deriveKycExceptionFields({ + ...newIndividual(true, true), + manualReasons: ['Address proof pending'] + }); + expect(result).toEqual({ + isException: true, + exceptionReason: 'Address proof pending', + exceptionResolved: false, + exceptionResolvedOn: null + }); + }); + + it('drops a stale automatic reason left in the form once the check passes', () => { + // The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since + // verified, so it must not survive into the payload. + const result = deriveKycExceptionFields({ + ...newIndividual(true, true), + manualReasons: [KYC_EXCEPTION_PAN] + }); + expect(result.isException).toBe(false); + expect(result.exceptionReason).toBeNull(); + }); + + it('does not duplicate a manual reason that matches an outstanding automatic one', () => { + const result = deriveKycExceptionFields({ + ...newIndividual(false, true), + manualReasons: [KYC_EXCEPTION_PAN] + }); + expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN); + }); + }); +}); diff --git a/src/app/clients/kyc-exception.util.ts b/src/app/clients/kyc-exception.util.ts new file mode 100644 index 000000000..9d5f4185b --- /dev/null +++ b/src/app/clients/kyc-exception.util.ts @@ -0,0 +1,112 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Derives a client's KYC exception state from their verification status. + * + * A KYC exception is not something an officer should have to remember to tick — it is simply the + * fact that a mandatory identity check has not passed. Deriving it means the exception list can + * never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN + * and Aadhaar are verified at the moment of the save. + * + * Shared by the client-creation stepper and the client-view KYC tab so both write identical state. + */ + +export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed'; +export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed'; + +/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */ +export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [ + KYC_EXCEPTION_PAN, + KYC_EXCEPTION_AADHAAR +]; + +export interface KycExceptionInput { + /** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */ + panVerified: boolean; + /** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */ + aadhaarVerified: boolean; + /** + * False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy + * masked value). Without this guard every company would carry a permanent Aadhaar exception. + */ + aadhaarApplicable: boolean; + /** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */ + hadException: boolean; + /** Reason currently persisted, kept verbatim when closing so the audit trail survives. */ + previousReason: string | null; + /** Officer-entered reasons beyond the two automatic ones; carried through untouched. */ + manualReasons?: string[]; + /** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */ + todayFormatted: string; +} + +export interface KycExceptionFields { + isException: boolean; + exceptionReason: string | null; + exceptionResolved: boolean; + exceptionResolvedOn: string | null; +} + +/** + * Maps verification status to the four exception columns the API expects. + * + * Three outcomes: + * - **Something outstanding** → raise/keep the exception, listing only what is still failing. A + * record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with + * Aadhaar alone, which is what narrows the entry rather than closing it. + * - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on + * purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of + * why the exception was ever raised. + * - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears + * in either tab. + */ +export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields { + const outstanding: string[] = []; + + if (!input.panVerified) { + outstanding.push(KYC_EXCEPTION_PAN); + } + if (input.aadhaarApplicable && !input.aadhaarVerified) { + outstanding.push(KYC_EXCEPTION_AADHAAR); + } + + // Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale + // 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified. + for (const reason of input.manualReasons || []) { + const trimmed = (reason || '').trim(); + if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) { + outstanding.push(trimmed); + } + } + + if (outstanding.length > 0) { + return { + isException: true, + exceptionReason: outstanding.join(', '), + exceptionResolved: false, + exceptionResolvedOn: null + }; + } + + if (input.hadException) { + return { + isException: false, + exceptionReason: input.previousReason || null, + exceptionResolved: true, + exceptionResolvedOn: input.todayFormatted + }; + } + + return { + isException: false, + exceptionReason: null, + exceptionResolved: false, + exceptionResolvedOn: null + }; +} diff --git a/src/app/core/authentication/authentication.service.ts b/src/app/core/authentication/authentication.service.ts index d45b6e302..d020ed3a8 100644 --- a/src/app/core/authentication/authentication.service.ts +++ b/src/app/core/authentication/authentication.service.ts @@ -384,6 +384,14 @@ export class AuthenticationService { return JSON.parse(this.storage.getItem(this.credentialsStorageKey)); } + /** Returns true when the logged-in user is at Head Office rather than a branch login. */ + isHeadOfficeUser(): boolean { + const creds = this.getCredentials(); + if (!creds) return false; + if (creds.officeId === 1) return true; + return (creds.officeName || '').toLowerCase().includes('head office'); + } + /** Returns true when the logged-in user has the "Clerk" role. */ isClerkUser(): boolean { const creds = this.getCredentials(); diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index c99ee3075..3ebc470d8 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -99,6 +99,30 @@ My Disbursements + + + + + + My Share Requests + + + + + + + My Share Closures + @if (clerkAccountLoaded) {
+ +@if (loading) { +
+ +
Loading live declarations and accounts from Fineract…
+
+} @else if (loadError) { +
+ +
Could not load real data from Fineract. Check connection.
+
+} @else { + +
+
+ +
+
{{ countGeneralBand() | number }}
+
General band (under 60)
+
+
+
+ +
+
{{ countSeniorBand() | number }}
+
Senior citizen band (60+)
+
+
+
+ +
+
{{ countFiled() | number }}
+
Filed this FY
+
+
+
+ +
+
₹{{ totalInterest() | number: '1.0-0' }}
+
Projected Interest
+
+
+
+ +
+
₹{{ totalTdsSaved() | number: '1.0-0' }}
+
TDS Saved
+
+
+ @if (countIneligible() > 0) { +
+ +
+
{{ countIneligible() | number }}
+
Not Eligible
+
+
+ } +
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (search || bandFilter || statusFilter || ineligibleOnly) { + + } +
+ + +
+
+ + + + + + + + + + + + + + + + + + @for (row of filteredRows; track row.row.record.customerId) { + + + + + + + + + + + + + + + @if (isExpanded(row)) { + + + + } + } + @if (filteredRows.length === 0) { + + + + } + +
Customer · PANForm · BandAgeBranchEst. InterestThresholdTDS PayableTDS SavedSettlementStatusActions
+
{{ row.row.record.customerName }}
+
{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}
+
DOB {{ row.row.record.dob | date: 'd MMM y' }}
+
+ + {{ row.row.form === '121' ? 'Form 121' : 'Form ' + row.row.form }} + +
{{ row.row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}
+
{{ row.row.age }} yrs{{ row.row.record.branch || '—' }} + ₹{{ row.row.record.estimatedInterest | number: '1.0-0' }} + ₹{{ row.row.schedule.threshold | number: '1.0-0' }} + @if (row.row.schedule.totalTds > 0) { + ₹{{ row.row.schedule.totalTds | number: '1.0-0' }} +
@ {{ row.row.schedule.rateApplied * 100 | number: '1.0-0' }}%
+ } @else { + — + } +
+ @if (row.row.tdsSaved > 0) { + ₹{{ row.row.tdsSaved | number: '1.0-0' }} + } @else { + — + } + + + {{ row.row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }} + +
product-driven
+
+ @if (row.row.record.status === 'FILED') { + Filed + @if (row.row.record.filedOn) { +
{{ row.row.record.filedOn | date: 'd MMM y' }}
+ } + } @else { + Pending + } + @if (!row.row.eligibility.eligible) { +
+ Not eligible +
+ } +
+
+ + + +
+
+ @if (!row.row.eligibility.eligible) { +
+ +
+ Declaration cannot be accepted +
    + @for (reason of row.row.eligibility.reasons; track reason) { +
  • {{ reason }}
  • + } +
+
+
+ } + @for (note of row.row.eligibility.unassessed; track note) { +
+ +
{{ note }}
+
+ } + @for (note of row.row.schedule.notes; track note) { +
+ +
{{ note }}
+
+ } + + + + + + + + + + + + + + @for (line of row.row.schedule.lines; track line.quarter) { + + + + + + + + + } + + + + + + + + + +
QuarterInterestCumulativeLiability to dateTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + threshold crossed + } + @if (line.declarationActive) { + Form 121 active + } + ₹{{ line.interest | number: '1.0-0' }}₹{{ line.cumInterest | number: '1.0-0' }}₹{{ line.cumLiability | number: '1.0-0' }} + @if (line.tds !== 0) { + ₹{{ line.tds | number: '1.0-0' }} + } @else { + — + } + {{ line.dueDate }}
Total + ₹{{ row.row.schedule.totalInterest | number: '1.0-0' }} + + ₹{{ row.row.schedule.totalTds | number: '1.0-0' }} +
+ + @if (row.row.schedule.shieldedInterest > 0) { +
+ ₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of + the declaration — reportable in the quarterly {{ statute.statement }} return. +
+ } +
+ + No active clients match the current filters. +
+
+
+} diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts new file mode 100644 index 000000000..1793ce61c --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.ts @@ -0,0 +1,502 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { forkJoin, of, Observable, Subject } from 'rxjs'; +import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { ClientsService } from '../../../../clients/clients.service'; +import { SavingsService } from '../../../../savings/savings.service'; +import { FixedDepositsService } from '../../../../deposits/fixed-deposits/fixed-deposits.service'; +import { RecurringDepositsService } from '../../../../deposits/recurring-deposits/recurring-deposits.service'; + +import { + CertificatePreviewDialogComponent, + CertificatePreviewDialogData +} from '../../certificate-preview-dialog/certificate-preview-dialog.component'; +import { downloadForm15ghPdf } from '../../certificate-pdf'; +import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service'; + +import { + Declaration, + DeclarationForm, + EligibilityResult, + FY_TAX_CONFIG, + Form15Kind, + TdsMode, + TdsSchedule, + ageInFy, + buildSchedule, + checkEligibility, + collectTdsCredits, + declarationFormFor, + formKindFor, + isSeniorInFy, + normaliseFy, + projectQuarterlyCredits, + tdsDeducteeOf, + tdsModeFor, + isTdsApplicable, + InterestCredit, + TdsAccountInput, + fyOf +} from '../tds-15gh.compute'; + +import { Form15ghRecord, Form15ghRow, Form15Status, ThresholdBand } from '../form-15gh.component'; + +export interface RealForm15ghRow { + row: Form15ghRow; + clientId: string; +} + +/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */ +function toIso(value: unknown): string | null { + if (Array.isArray(value) && value.length >= 3) { + const [y, m, d] = value as number[]; + return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`; + } + if (typeof value === 'string' && value.trim()) { + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) { + return parsed.toISOString().slice(0, 10); + } + } + return null; +} + +@Component({ + selector: 'mifosx-real-form15gh-view', + templateUrl: './real-form15gh-view.component.html', + styleUrls: ['../../certificates.shared.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CommonModule, + FormsModule, + FaIconComponent, + MatTooltip + ] +}) +export class RealForm15ghViewComponent implements OnInit, OnDestroy { + private dialog = inject(MatDialog); + private snack = inject(MatSnackBar); + private clientsService = inject(ClientsService); + private savingsService = inject(SavingsService); + private fixedDepositsService = inject(FixedDepositsService); + private recurringDepositsService = inject(RecurringDepositsService); + private dscSign = inject(DscSignService); + + private destroy$ = new Subject(); + + loading = true; + loadError = false; + rows: RealForm15ghRow[] = []; + + // ── Filters ── + search = ''; + bandFilter: '' | ThresholdBand = ''; + statusFilter: '' | Form15Status = ''; + ineligibleOnly = false; + + /** Which row's quarter-wise breakdown is open. */ + expandedId: string | null = null; + selectedFy = 'FY 2026-27'; + + // Per-client caches + private clientDataCache = new Map>(); + private clientKycCache = new Map>(); + private clientAccountsCache = new Map>(); + + readonly availableFys = ['FY 2026-27', 'FY 2025-26']; + + ngOnInit(): void { + this.loadRealData(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + loadRealData(): void { + this.loading = true; + this.loadError = false; + this.clientDataCache.clear(); + this.clientKycCache.clear(); + this.clientAccountsCache.clear(); + + // Fetch up to 100 active individual clients. + this.clientsService + .searchByText('', 0, 100, '', '', true, 'INDIVIDUAL', true) + .pipe( + takeUntil(this.destroy$), + map((page: any) => page?.content || page?.pageItems || (Array.isArray(page) ? page : [])), + switchMap((clients: any[]) => { + if (!clients.length) { + return of([] as RealForm15ghRow[]); + } + return forkJoin(clients.map((c) => this.buildRealRow(c))).pipe( + map((rows) => rows.filter((r): r is RealForm15ghRow => r !== null)) + ); + }), + catchError(() => { + this.loadError = true; + return of([] as RealForm15ghRow[]); + }) + ) + .subscribe((rows) => { + this.rows = rows; + this.loading = false; + }); + } + + private buildRealRow(clientSummary: any): Observable { + const clientId = clientSummary?.id != null ? String(clientSummary.id) : null; + if (!clientId) { + return of(null); + } + + return forkJoin({ + client: this.getClientDataCached(clientId), + kyc: this.getClientKycCached(clientId), + accountsSummary: this.getClientAccountsCached(clientId) + }).pipe( + switchMap(({ client, kyc, accountsSummary }) => { + const activeSavings = (accountsSummary?.savingsAccounts ?? []).filter( + (a: any) => a.status?.active || a.status?.matured || a.status?.closed + ); + + if (!activeSavings.length) { + // If no accounts, we can't calculate interest. But we can still build an empty row if a declaration is filed + return of({ client, kyc, accountDetailsList: [] }); + } + + return forkJoin(activeSavings.map((a: any) => this.fetchAccountDetails(a))).pipe( + map((detailsList) => ({ + client, + kyc, + accountDetailsList: (detailsList as any[]).filter(Boolean) + })) + ); + }), + map(({ client, kyc, accountDetailsList }) => { + if (!client) { + return null; + } + const record = this.mapToRecord(client, kyc, accountDetailsList); + if (!record) { + return null; + } + const row = this.computeRow(record, accountDetailsList); + return { + row, + clientId + }; + }), + catchError(() => of(null)) + ); + } + + private getClientDataCached(clientId: string): Observable { + if (!this.clientDataCache.has(clientId)) { + this.clientDataCache.set( + clientId, + this.clientsService.getClientData(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientDataCache.get(clientId)!; + } + + private getClientKycCached(clientId: string): Observable { + if (!this.clientKycCache.has(clientId)) { + this.clientKycCache.set( + clientId, + this.clientsService.getClientKyc(clientId, true).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientKycCache.get(clientId)!; + } + + private getClientAccountsCached(clientId: string): Observable { + if (!this.clientAccountsCache.has(clientId)) { + this.clientAccountsCache.set( + clientId, + this.clientsService.getClientAccountData(clientId).pipe( + catchError(() => of(null)), + shareReplay(1) + ) + ); + } + return this.clientAccountsCache.get(clientId)!; + } + + private fetchAccountDetails(accountSummary: any): Observable { + const id = String(accountSummary.id); + const code = accountSummary.depositType?.code ?? ''; + let call: Observable; + if (code.includes('fixedDeposit')) { + call = this.fixedDepositsService.getFixedDepositsAccountData(id); + } else if (code.includes('recurringDeposit')) { + call = this.recurringDepositsService.getRecurringDepositsAccountData(id); + } else { + call = this.savingsService.getSavingsAccountData(id); + } + return call.pipe(catchError(() => of(null))); + } + + private mapToRecord(client: any, kyc: any, detailsList: any[]): Form15ghRecord | null { + const dob = toIso(client?.dateOfBirth) ?? ''; + if (!dob) { + return null; + } + + const fy = normaliseFy(this.selectedFy); + const hasDeclaration = !!kyc?.form15Applicable && normaliseFy(kyc?.form15Fy ?? '') === fy; + const status: Form15Status = hasDeclaration ? 'FILED' : 'PENDING'; + + // Sum estimated time deposit interest for the selected year + let estimatedInterest = 0; + let depositPrincipal = 0; + + for (const d of detailsList) { + const code = d.depositType?.code ?? ''; + const isTimeDeposit = code.includes('fixedDeposit') || code.includes('recurringDeposit'); + if (isTimeDeposit) { + depositPrincipal += d.depositAmount ?? d.nominalAnnualInterestRate ?? 0; + // Sum interest transactions in the selected FY + for (const t of d.transactions ?? []) { + if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) { + continue; + } + const iso = toIso(t.date); + if (iso && fyOf(iso) === fy) { + estimatedInterest += t.amount ?? 0; + } + } + } + } + + // Default to a small placeholder principal/interest if the live accounts have no transaction history yet, + // so the table is not completely blank of figures. + if (estimatedInterest === 0 && depositPrincipal > 0) { + estimatedInterest = Math.round(depositPrincipal * 0.07); + } + + return { + customerId: client.accountNo || String(client.id), + customerName: client.displayName || '', + customerPan: (kyc?.panNumber ?? '').toUpperCase(), + customerAddress: client.officeName || '', + dob, + branch: client.officeName || '', + estimatedInterest, + estimatedIncome: kyc?.estimatedIncome ?? 0, + depositPrincipal, + fy: this.selectedFy, + status, + filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined, + assessed: true, + interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly' + }; + } + + private computeRow(record: Form15ghRecord, detailsList: any[]): Form15ghRow { + const ctx = { + fy: record.fy, + dateOfBirth: record.dob, + pan: record.customerPan, + resident: true + }; + + const declaration: Declaration | null = + record.status === 'FILED' + ? { + kind: formKindFor(record.dob, record.fy), + acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`, + fy: record.fy + } + : null; + + const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType }); + + // Map the actual account transactions to InterestCredit inputs for the schedule calculation + const accountInputs: TdsAccountInput[] = detailsList.map((d) => { + const code = d.depositType?.code ?? ''; + const extra = d.extraAttributes ?? d.externalAttributes ?? d; + const ref = { + id: d.id, + accountNo: d.accountNo ?? String(d.id), + depositTypeCode: code, + accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE', + clientId: d.clientId ?? Number(record.customerId), + primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null, + guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null, + jointHolders: extra.jointHolders ?? d.jointHolders ?? [], + interestPostingPeriodType: d.interestPostingPeriodType ?? null + }; + + const credits: InterestCredit[] = []; + for (const t of d.transactions ?? []) { + if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) { + continue; + } + const iso = toIso(t.date); + if (iso) { + credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo }); + } + } + + return { + ...ref, + credits + }; + }); + + const collectedCredits = collectTdsCredits(Number(record.customerId), accountInputs, record.fy); + // If no real transactions are recorded yet, fall back to spreading the estimated interest + const creditsToUse = collectedCredits.length + ? collectedCredits + : projectQuarterlyCredits(record.estimatedInterest, record.fy); + + const schedule = buildSchedule(mode, creditsToUse, { ...ctx, declaration }); + const withoutDeclaration = buildSchedule(mode, creditsToUse, { ...ctx, declaration: null }); + + return { + record, + form: declarationFormFor(record.fy, record.dob), + kind: formKindFor(record.dob, record.fy), + band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL', + age: ageInFy(record.dob, record.fy), + mode, + schedule, + tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0, + eligibility: checkEligibility(ctx, { + estimatedInterest: record.estimatedInterest, + estimatedIncome: record.estimatedIncome || 250000, + incomeOnRecord: true + }) + }; + } + + get filteredRows(): RealForm15ghRow[] { + const q = this.search.trim().toLowerCase(); + return this.rows.filter(({ row }) => { + if (this.bandFilter && row.band !== this.bandFilter) { + return false; + } + if (this.statusFilter && row.record.status !== this.statusFilter) { + return false; + } + if (this.ineligibleOnly && row.eligibility.eligible) { + return false; + } + if (!q) { + return true; + } + const r = row.record; + return ( + r.customerName.toLowerCase().includes(q) || + r.customerId.toLowerCase().includes(q) || + r.customerPan.toLowerCase().includes(q) + ); + }); + } + + countGeneralBand(): number { + return this.rows.filter((r) => r.row.band === 'GENERAL').length; + } + + countSeniorBand(): number { + return this.rows.filter((r) => r.row.band === 'SENIOR').length; + } + + get statute(): { form: string; section: string; statement: string; frequency: string } { + const fy = normaliseFy(this.selectedFy); + const cfg = FY_TAX_CONFIG[fy]; + return { + form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', + section: cfg?.interestTdsSection ?? '—', + statement: cfg?.tdsStatementForm ?? '—', + frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' + }; + } + + countFiled(): number { + return this.rows.filter((r) => r.row.record.status === 'FILED').length; + } + + countIneligible(): number { + return this.rows.filter((r) => !r.row.eligibility.eligible).length; + } + + totalInterest(): number { + return this.filteredRows.reduce((s, r) => s + r.row.record.estimatedInterest, 0); + } + + totalTdsSaved(): number { + return this.filteredRows.reduce((s, r) => s + r.row.tdsSaved, 0); + } + + clearFilters(): void { + this.search = ''; + this.bandFilter = ''; + this.statusFilter = ''; + this.ineligibleOnly = false; + } + + toggleExpanded(row: RealForm15ghRow): void { + this.expandedId = this.expandedId === row.row.record.customerId ? null : row.row.record.customerId; + } + + isExpanded(row: RealForm15ghRow): boolean { + return this.expandedId === row.row.record.customerId; + } + + viewCertificate(row: RealForm15ghRow): void { + const data: CertificatePreviewDialogData = { + kind: 'form15gh', + form15gh: { record: row.row.record, kind: row.row.kind, age: row.row.age } + }; + this.dialog.open(CertificatePreviewDialogComponent, { + width: '880px', + maxWidth: '96vw', + maxHeight: '92vh', + autoFocus: false, + panelClass: 'cert-preview-dialog', + data + }); + } + + downloadCertificate(row: RealForm15ghRow): void { + const r = row.row.record; + if (!row.row.eligibility.eligible) { + this.snack.open(`Cannot issue: ${row.row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 }); + return; + } + this.dscSign.promptThenDownload( + { + title: `${row.row.form === '121' ? 'Form 121' : `Form ${row.row.form}`} Declaration`, + reference: `${r.customerId} · ${r.customerName} · ${r.fy}`, + filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf` + }, + (dsc) => downloadForm15ghPdf(r, row.row.kind, dsc) + ); + } +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts new file mode 100644 index 000000000..636979647 --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.spec.ts @@ -0,0 +1,766 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { + FY_TAX_CONFIG, + InterestCredit, + TdsAccountInput, + TdsContext, + ageInFy, + declarationFormFor, + annualSchedule, + bucketByQuarter, + checkEligibility, + collectTdsCredits, + cumulativeLiability, + formKindFor, + fyOf, + isTdsApplicable, + isWellFormedPan, + lateDeductionInterest, + monthsOrPartBetween, + projectQuarterlyCredits, + quarterlySchedule, + reconcile, + tdsDeducteeOf, + tdsModeFor +} from './tds-15gh.compute'; + +const FY = 'FY 2026-27'; + +/** Under 60 for FY 2026-27, valid individual PAN. */ +function general(overrides: Partial = {}): TdsContext { + return { fy: FY, dateOfBirth: '1985-02-14', pan: 'ABCPM1234E', resident: true, ...overrides }; +} + +/** 60+ for FY 2026-27. */ +function senior(overrides: Partial = {}): TdsContext { + return { fy: FY, dateOfBirth: '1958-06-22', pan: 'ABCPM1234E', resident: true, ...overrides }; +} + +/** Four dated credits, one per quarter. */ +function credits(q1: number, q2: number, q3: number, q4: number): InterestCredit[] { + return [ + { date: '2026-06-30', amount: q1 }, + { date: '2026-09-30', amount: q2 }, + { date: '2026-12-31', amount: q3 }, + { date: '2027-03-31', amount: q4 } + ]; +} + +describe('194A threshold behaviour', () => { + it('deducts nothing while the aggregate stays under the trigger', () => { + expect(quarterlySchedule(credits(12_000, 12_000, 12_000, 12_000), general()).totalTds).toBe(0); + }); + + it('deducts nothing when the aggregate lands exactly on the trigger', () => { + // The section bites only where the amount *exceeds* the limit. + expect(quarterlySchedule(credits(12_500, 12_500, 12_500, 12_500), general()).totalTds).toBe(0); + }); + + it('treats the threshold as a trigger, not an allowance', () => { + // ₹52,000 of interest: 10% of the whole ₹52,000, not 10% of the ₹2,000 above + // the ₹50,000 line. + const s = annualSchedule(credits(13_000, 13_000, 13_000, 13_000), general()); + expect(s.totalTds).toBe(5_200); + expect(s.totalTds).not.toBe(200); + }); + + it('applies the senior citizen trigger of ₹1,00,000', () => { + const c = credits(20_000, 20_000, 20_000, 20_000); // ₹80,000 + expect(quarterlySchedule(c, general()).totalTds).toBe(8_000); + expect(quarterlySchedule(c, senior()).totalTds).toBe(0); + }); + + it('falls back to the 206AA rate of 20% where PAN is not on record', () => { + const c = credits(25_000, 25_000, 25_000, 25_000); // ₹1,00,000 + expect(annualSchedule(c, general()).totalTds).toBe(10_000); + expect(annualSchedule(c, general({ pan: '' })).totalTds).toBe(20_000); + }); +}); + +describe('the quarterly schedule catches up in the quarter the trigger is crossed', () => { + it('loads the whole year-to-date liability into that quarter', () => { + // ₹20,000 a quarter. The aggregate crosses ₹50,000 during Q3. + const s = quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general()); + expect(s.lines.map((l) => l.tds)).toEqual([ + 0, + 0, + 6_000, + 2_000 + ]); + expect(s.lines[2].thresholdCrossedHere).toBe(true); + // Q3 deducts ₹6,000 against ₹20,000 of that quarter's own interest. That is + // correct: the earlier quarters became liable the moment the line was + // crossed, and the branch collects the arrears now. + expect(s.totalTds).toBe(8_000); + }); + + it('never deducts a negative amount on a rising book', () => { + const s = quarterlySchedule(credits(5_000, 60_000, 1_000, 400), general()); + for (const line of s.lines) { + expect(line.tds).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe('quarterly and annual settlement agree to the rupee', () => { + const distributions: [number, number, number, number][] = [ + [ + 12_000, + 12_000, + 12_000, + 12_000 + ], + [ + 20_000, + 20_000, + 20_000, + 20_000 + ], + [ + 60_000, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 75_000 + ], + [ + 49_999, + 1, + 0, + 0 + ], + [ + 13_333, + 13_333, + 13_333, + 13_334 + ], + [ + 1, + 2, + 3, + 99_999 + ], + [ + 250_000, + 250_000, + 250_000, + 250_000 + ] + ]; + + it.each(distributions)('holds for %i / %i / %i / %i', (q1, q2, q3, q4) => { + const c = credits(q1, q2, q3, q4); + for (const ctx of [ + general(), + senior(), + general({ pan: '' }) + ]) { + const q = quarterlySchedule(c, ctx); + const a = annualSchedule(c, ctx); + expect(q.totalTds).toBe(a.totalTds); + expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(a.totalTds); + } + }); + + it('holds under section 288B rounding to the nearest ₹10', () => { + const ctx = general({ config: { roundTo: 10 } }); + const c = credits(13_333, 13_333, 13_333, 13_334); + expect(quarterlySchedule(c, ctx).totalTds).toBe(annualSchedule(c, ctx).totalTds); + expect(annualSchedule(c, ctx).totalTds % 10).toBe(0); + }); + + /** + * Property test. Rounding drift only shows up on awkward numbers, so sweep a + * deterministic spread of them rather than trusting hand-picked cases. + */ + it('holds across 2000 pseudo-random interest distributions', () => { + let seed = 20260801; + const next = (max: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return Math.floor((seed / 4294967296) * max); + }; + + for (let i = 0; i < 2000; i++) { + const c = credits(next(90_000), next(90_000), next(90_000), next(90_000)); + const ctx = general({ config: { roundTo: i % 2 === 0 ? 1 : 10 } }); + const q = quarterlySchedule(c, ctx); + const a = annualSchedule(c, ctx); + if (q.totalTds !== a.totalTds) { + throw new Error( + `Mismatch on ${c.map((x) => x.amount).join('/')}: quarterly ${q.totalTds} vs annual ${a.totalTds}` + ); + } + expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(q.totalTds); + } + }); + + it('holds across 2000 distributions with a declaration at every acceptance point', () => { + let seed = 19980731; + const next = (max: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return Math.floor((seed / 4294967296) * max); + }; + const dates = [ + '2026-04-01', + '2026-05-15', + '2026-08-01', + '2026-10-01', + '2027-01-10', + '2027-03-31' + ]; + + for (let i = 0; i < 2000; i++) { + const c = credits(next(90_000), next(90_000), next(90_000), next(90_000)); + const ctx = general({ + declaration: { kind: '15G', acceptedOn: dates[i % dates.length], fy: '2026-27' }, + config: { roundTo: i % 2 === 0 ? 1 : 10 } + }); + const q = quarterlySchedule(c, ctx); + const a = annualSchedule(c, ctx); + if (q.totalTds !== a.totalTds) { + throw new Error( + `Mismatch on ${c.map((x) => x.amount).join('/')} declared ${dates[i % dates.length]}: ` + + `quarterly ${q.totalTds} vs annual ${a.totalTds}` + ); + } + } + }); + + it('adjusts within the year when interest is reversed', () => { + // Q2 took the aggregate to ₹80,000 and ₹8,000 was deducted. The reversal + // brings the year back to ₹50,000, which does not exceed the trigger at all, + // so the whole ₹8,000 is given back in Q3. + const c = credits(40_000, 40_000, -30_000, 0); + const q = quarterlySchedule(c, general()); + expect(q.lines.map((l) => l.tds)).toEqual([ + 0, + 8_000, + -8_000, + 0 + ]); + expect(q.totalTds).toBe(0); + expect(annualSchedule(c, general()).totalTds).toBe(0); + }); +}); + +describe('a Form 121 declaration', () => { + const fromApril = general({ + declaration: { kind: '15G', acceptedOn: '2026-04-01', fy: '2026-27', uin: 'U0001' } + }); + const fromOctober = general({ + declaration: { kind: '15G', acceptedOn: '2026-10-01', fy: '2026-27' } + }); + + it('stops deduction for the whole year when on record from April', () => { + const c = credits(30_000, 30_000, 30_000, 30_000); + expect(quarterlySchedule(c, fromApril).totalTds).toBe(0); + expect(annualSchedule(c, fromApril).totalTds).toBe(0); + expect(reconcile(c, fromApril).matched).toBe(true); + }); + + it('shields only the interest credited on or after it was accepted', () => { + const s = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober); + expect(s.liableInterest).toBe(60_000); + expect(s.shieldedInterest).toBe(60_000); + }); + + it('does not claw back tax already remitted before it arrived', () => { + const q = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober); + expect(q.lines.map((l) => l.tds)).toEqual([ + 0, + 6_000, + 0, + 0 + ]); + expect(q.totalTds).toBe(6_000); + expect(q.notes[0]).toContain('cannot reverse it'); + }); + + it('leaves the two modes in agreement even when taken mid-year', () => { + // A declaration is prospective, so the year-end figure still sees the + // pre-declaration interest as liable. + const r = reconcile(credits(30_000, 30_000, 30_000, 30_000), fromOctober); + expect(r.quarterlyTotal).toBe(6_000); + expect(r.annualTotal).toBe(6_000); + expect(r.matched).toBe(true); + }); + + it('reports the divergence when liability falls after deductions were frozen', () => { + // Q1 crossed the trigger and ₹6,000 went to the Government. A reversal in + // Q3, after the declaration was on record, drags the year back under + // ₹50,000 — the liability is now nil but the branch cannot refund. + const c: InterestCredit[] = [ + { date: '2026-06-30', amount: 60_000 }, + { date: '2026-12-31', amount: -15_000 } + ]; + const r = reconcile(c, fromOctober); + expect(r.quarterlyTotal).toBe(6_000); + expect(r.annualTotal).toBe(0); + expect(r.matched).toBe(false); + expect(quarterlySchedule(c, fromOctober).lines[2].tds).toBe(0); + }); +}); + +describe('age is measured against the financial year, not today', () => { + it('gives the senior threshold to a customer who turns 60 in March', () => { + expect(ageInFy('1967-03-20', FY)).toBe(60); + expect(formKindFor('1967-03-20', FY)).toBe('15H'); + const ctx = general({ dateOfBirth: '1967-03-20' }); + expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), ctx).threshold).toBe(100_000); + }); + + it('keeps the general threshold for a customer who turns 60 the next April', () => { + expect(ageInFy('1967-04-20', FY)).toBe(59); + expect(formKindFor('1967-04-20', FY)).toBe('15G'); + }); +}); + +describe('savings interest is outside section 194A', () => { + it.each([ + [ + 'depositAccountType.fixedDeposit', + true + ], + [ + 'depositAccountType.recurringDeposit', + true + ], + [ + 'depositAccountType.savingsDeposit', + false + ], + [ + 'depositAccountType.currentDeposit', + false + ], + [ + '', + false + ] + ])('%s -> %s', (code, expected) => { + expect(isTdsApplicable({ depositTypeCode: code })).toBe(expected); + }); + + it('keeps savings interest out of the aggregate entirely', () => { + const accounts: TdsAccountInput[] = [ + { + accountNo: 'FD001', + depositTypeCode: 'depositAccountType.fixedDeposit', + accountStatus: 'SINGLE', + clientId: 1, + credits: credits(10_000, 10_000, 10_000, 10_000) + }, + { + accountNo: 'SB001', + depositTypeCode: 'depositAccountType.savingsDeposit', + accountStatus: 'SINGLE', + clientId: 1, + credits: credits(5_000, 5_000, 5_000, 5_000) + } + ]; + const collected = collectTdsCredits(1, accounts, FY); + + // ₹40,000 of FD interest, not ₹60,000 — so the trigger is never crossed and + // nothing is deducted. Folding the savings interest in would have crossed it. + expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(40_000); + expect(quarterlySchedule(collected, general()).totalTds).toBe(0); + }); +}); + +describe('whose PAN the tax is deducted against', () => { + it('uses the account holder for a single-name account', () => { + expect(tdsDeducteeOf({ accountStatus: 'SINGLE', clientId: 7 })).toBe(7); + expect(tdsDeducteeOf({ accountStatus: 'INDIVIDUAL_MALE', clientId: 7 })).toBe(7); + }); + + it('uses the first-named holder on a joint account', () => { + expect( + tdsDeducteeOf({ + accountStatus: 'JOINT', + primaryClientId: 7, + jointHolders: [{ clientId: 9 }] + }) + ).toBe(7); + }); + + it('uses the guardian on a minor account', () => { + expect(tdsDeducteeOf({ accountStatus: 'MINOR', clientId: 11, guardianClientId: 4 })).toBe(4); + }); + + it('does not count a joint deposit twice across the two holders', () => { + const jointFd: TdsAccountInput = { + accountNo: 'FD-JOINT', + depositTypeCode: 'depositAccountType.fixedDeposit', + accountStatus: 'JOINT', + primaryClientId: 7, + jointHolders: [{ clientId: 9 }], + credits: credits(15_000, 15_000, 15_000, 15_000) // ₹60,000 + }; + + // Primary holder carries the whole ₹60,000 and the whole ₹6,000 of tax. + const primary = collectTdsCredits(7, [jointFd], FY); + expect(primary.reduce((s, c) => s + c.amount, 0)).toBe(60_000); + expect(quarterlySchedule(primary, general()).totalTds).toBe(6_000); + + // The second holder carries none of it — no double deduction on one deposit. + const secondHolder = collectTdsCredits(9, [jointFd], FY); + expect(secondHolder).toEqual([]); + expect(quarterlySchedule(secondHolder, general()).totalTds).toBe(0); + }); + + it('ignores any share percentage when attributing interest', () => { + // A 50/50 joint account still reports the full amount against the primary + // holder; splitting would produce two deductees for one deposit. + const jointFd: TdsAccountInput = { + accountNo: 'FD-JOINT', + depositTypeCode: 'depositAccountType.fixedDeposit', + accountStatus: 'JOINT', + primaryClientId: 7, + jointHolders: [{ clientId: 9 }], + credits: credits(15_000, 15_000, 15_000, 15_000) + }; + expect(collectTdsCredits(7, [jointFd], FY).reduce((s, c) => s + c.amount, 0)).toBe(60_000); + }); + + it('aggregates a customer across every branch', () => { + // Three deposits in three branches, each under the trigger on its own. + const accounts: TdsAccountInput[] = [ + 'FD-MUM', + 'FD-PUN', + 'FD-NAS' + ].map((accountNo, i) => ({ + accountNo, + depositTypeCode: 'depositAccountType.fixedDeposit', + accountStatus: 'SINGLE', + clientId: 1, + credits: [{ date: `2026-0${6 + i}-30`, amount: [ + 20_000, + 18_000, + 15_000 + ][i] }] + })); + + const collected = collectTdsCredits(1, accounts, FY); + expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(53_000); + // Branch by branch none would have crossed ₹50,000. Bank-wide, it does. + expect(quarterlySchedule(collected, general()).totalTds).toBe(5_300); + }); + + it('keeps other financial years out of the selected year', () => { + const account: TdsAccountInput = { + accountNo: 'FD001', + depositTypeCode: 'depositAccountType.fixedDeposit', + accountStatus: 'SINGLE', + clientId: 1, + credits: [ + { date: '2026-03-31', amount: 99_000 }, // FY 2025-26 + { date: '2026-06-30', amount: 10_000 }, // FY 2026-27 + { date: '2027-04-01', amount: 99_000 } // FY 2027-28 + ] + }; + expect(collectTdsCredits(1, [account], FY).map((c) => c.amount)).toEqual([10_000]); + }); +}); + +describe('settlement mode follows the product, not the customer', () => { + it.each([ + [ + 'savingsPostingInterestPeriodType.monthly', + 'QUARTERLY' + ], + [ + 'savingsPostingInterestPeriodType.quarterly', + 'QUARTERLY' + ], + [ + 'savingsPostingInterestPeriodType.annual', + 'ANNUAL' + ], + [ + 'savingsPostingInterestPeriodType.annually', + 'ANNUAL' + ] + ])('%s -> %s', (code, expected) => { + expect(tdsModeFor({ interestPostingPeriodType: { code } })).toBe(expected); + }); + + it('does not mistake a half-yearly product for an annual one', () => { + // 'biannual' contains 'annual' — a naive substring test settles half-yearly + // products once a year, which is a deduction default. + expect(tdsModeFor({ interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.biannual' } })).toBe( + 'QUARTERLY' + ); + expect(tdsModeFor({ interestPostingPeriodType: { value: 'Bi-Annual' } })).toBe('QUARTERLY'); + }); + + it('reads the value when no code is present, and defaults safely', () => { + expect(tdsModeFor({ interestPostingPeriodType: { value: 'Annually' } })).toBe('ANNUAL'); + // Unknown or missing configuration settles on the quarterly cycle, which is + // the conservative choice — deducting sooner is never a default. + expect(tdsModeFor({})).toBe('QUARTERLY'); + expect(tdsModeFor({ interestPostingPeriodType: null })).toBe('QUARTERLY'); + }); +}); + +describe('PAN validation', () => { + it.each([ + [ + 'ABCPM1234E', + true + ], + [ + 'EFGHV5678A', + true + ], + [ + 'PQRSR2233Z', + false + ], + [ + 'TUVWS3344K', + false + ], + [ + 'ABCP1234E', + false + ], + [ + '', + false + ] + ])('%s -> %s', (pan, expected) => { + expect(isWellFormedPan(pan)).toBe(expected); + }); +}); + +const income = (estimatedInterest: number, estimatedIncome: number) => ({ + estimatedInterest, + estimatedIncome, + incomeOnRecord: true +}); + +/** FY 2025-26 — the last year the 1961 Act and the 15G/15H split applied. */ +const OLD_FY = 'FY 2025-26'; + +describe('which declaration form is in force', () => { + it('splits by age up to FY 2025-26', () => { + expect(declarationFormFor(OLD_FY, '1985-02-14')).toBe('15G'); + expect(declarationFormFor(OLD_FY, '1958-06-22')).toBe('15H'); + }); + + it('is a single Form 121 from FY 2026-27, whatever the age', () => { + expect(declarationFormFor(FY, '1985-02-14')).toBe('121'); + expect(declarationFormFor(FY, '1958-06-22')).toBe('121'); + }); + + it('keeps the senior threshold even though the forms merged', () => { + // The two forms became one, but the deduction trigger is still age-based. + expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general()).threshold).toBe(50_000); + expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), senior()).threshold).toBe(100_000); + }); +}); + +describe('eligibility up to FY 2025-26 — the 15G / 15H tests', () => { + const old = (o: Partial = {}) => general({ fy: OLD_FY, ...o }); + const oldSenior = (o: Partial = {}) => senior({ fy: OLD_FY, ...o }); + + it('accepts a 15G within both the income and the interest ceiling', () => { + const r = checkEligibility(old(), income(90_000, 320_000)); + expect(r.form).toBe('15G'); + expect(r.eligible).toBe(true); + }); + + it('rejects a 15G whose estimated income exceeds the basic exemption', () => { + const r = checkEligibility(old(), income(38_500, 850_000)); + expect(r.eligible).toBe(false); + expect(r.reasons.join(' ')).toContain('exceeds the basic exemption'); + }); + + it('rejects a 15G whose interest alone exceeds the basic exemption', () => { + // The second limb: interest had to clear the limit as well as income. + expect(checkEligibility(old(), income(450_000, 380_000)).eligible).toBe(false); + }); + + it('places no interest ceiling on a 15H', () => { + // A senior citizen with ₹8,00,000 of interest could still declare, provided + // deductions and rebate left the liability at nil. + const r = checkEligibility(oldSenior(), income(800_000, 900_000)); + expect(r.form).toBe('15H'); + expect(r.eligible).toBe(true); + }); + + it('rejects a 15H that leaves a liability', () => { + const r = checkEligibility(oldSenior(), income(800_000, 1_500_000)); + expect(r.eligible).toBe(false); + expect(r.reasons.join(' ')).toContain('nil'); + }); + + it('cites the rule that applied to that year', () => { + const r = checkEligibility(old({ pan: '' }), income(10_000, 100_000)); + expect(r.eligible).toBe(false); + expect(r.reasons.join(' ')).toContain('Rule 29C'); + }); + + it('still checks the interest limb when income is not on record', () => { + // Income has to be declared by the payee, but the bank knows the interest + // figure, so that limb remains assessable. + expect( + checkEligibility(old(), { estimatedInterest: 450_000, estimatedIncome: 0, incomeOnRecord: false }).eligible + ).toBe(false); + }); +}); + +describe('eligibility from FY 2026-27 — the single Form 121 test', () => { + it('drops the interest limb entirely', () => { + // Under a 15G this failed: interest of ₹4,50,000 was over the basic + // exemption limit. Form 121 asks only whether the tax liability is nil, so + // the same payee is now eligible. + expect(checkEligibility(general({ fy: OLD_FY }), income(450_000, 380_000)).eligible).toBe(false); + const r = checkEligibility(general(), income(450_000, 380_000)); + expect(r.form).toBe('121'); + expect(r.eligible).toBe(true); + }); + + it('applies one nil-liability ceiling to every age', () => { + for (const ctx of [ + general(), + senior() + ]) { + expect(checkEligibility(ctx, income(800_000, 900_000)).eligible).toBe(true); + const over = checkEligibility(ctx, income(800_000, 1_500_000)); + expect(over.eligible).toBe(false); + expect(over.reasons.join(' ')).toContain('nil'); + } + }); + + it('takes PAN only — Aadhaar is not a substitute', () => { + const r = checkEligibility(general({ pan: '' }), income(10_000, 100_000)); + expect(r.eligible).toBe(false); + expect(r.reasons.join(' ')).toContain('Rule 211'); + expect(r.reasons.join(' ')).toContain('Aadhaar is not a substitute'); + }); + + it('refuses an entity PAN', () => { + const r = checkEligibility(general({ pan: 'ABCCM1234E' }), income(10_000, 100_000)); + expect(r.eligible).toBe(false); + expect(r.reasons.join(' ')).toContain('entity'); + }); + + it('refuses a non-resident', () => { + expect(checkEligibility(general({ resident: false }), income(10_000, 100_000)).eligible).toBe(false); + }); + + it('reports the nil-liability test as unassessed rather than passing it silently', () => { + // The branch cannot compute income earned outside the bank, so a missing + // figure must not read as a pass. + const r = checkEligibility(general(), { + estimatedInterest: 90_000, + estimatedIncome: 0, + incomeOnRecord: false + }); + expect(r.unassessed.length).toBe(1); + expect(r.unassessed[0]).toContain('not on record'); + // With no interest limb left, nothing else can fail it — so the branch must + // read the unassessed note rather than treat the row as cleared. + expect(r.eligible).toBe(true); + }); +}); + +describe('statutory references travel with the year', () => { + it('quotes the 1961 Act for FY 2025-26 and the 2025 Act for FY 2026-27', () => { + expect(FY_TAX_CONFIG['2025-26'].regime).toBe('ITA_1961'); + expect(FY_TAX_CONFIG['2025-26'].interestTdsSection).toContain('194A'); + expect(FY_TAX_CONFIG['2025-26'].tdsStatementForm).toBe('Form 26Q'); + expect(FY_TAX_CONFIG['2025-26'].declarationStatementFrequency).toBe('QUARTERLY'); + + expect(FY_TAX_CONFIG['2026-27'].regime).toBe('ITA_2025'); + expect(FY_TAX_CONFIG['2026-27'].interestTdsSection).toContain('393'); + expect(FY_TAX_CONFIG['2026-27'].tdsStatementForm).toBe('Form 140'); + expect(FY_TAX_CONFIG['2026-27'].declarationStatementFrequency).toBe('MONTHLY'); + }); + + it('carries the same thresholds and rates across the changeover', () => { + const a = FY_TAX_CONFIG['2025-26']; + const b = FY_TAX_CONFIG['2026-27']; + expect(b.thresholdGeneral).toBe(a.thresholdGeneral); + expect(b.thresholdSenior).toBe(a.thresholdSenior); + expect(b.rate).toBe(a.rate); + expect(b.rateNoPan).toBe(a.rateNoPan); + }); +}); + +describe('default interest under section 201(1A)', () => { + it('counts any part of a month as a full month', () => { + expect(monthsOrPartBetween('2026-07-07', '2026-07-07')).toBe(0); + expect(monthsOrPartBetween('2026-07-07', '2026-07-08')).toBe(1); + expect(monthsOrPartBetween('2026-07-07', '2026-08-07')).toBe(1); + expect(monthsOrPartBetween('2026-07-07', '2026-08-08')).toBe(2); + expect(monthsOrPartBetween('2026-07-07', '2027-01-07')).toBe(6); + }); + + it('charges 1% a month for deducting late and 1.5% for depositing late', () => { + expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEDUCTION', FY)).toEqual({ + months: 6, + rate: 0.01, + interest: 360 + }); + expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEPOSIT', FY).interest).toBe(540); + }); + + it('charges nothing when settled on time', () => { + expect(lateDeductionInterest(6_000, '2026-07-07', '2026-07-07', 'DEPOSIT', FY).interest).toBe(0); + }); +}); + +describe('helpers', () => { + it('resolves the financial year of a date', () => { + expect(fyOf('2026-04-01')).toBe('2026-27'); + expect(fyOf('2027-03-31')).toBe('2026-27'); + expect(fyOf('2026-03-31')).toBe('2025-26'); + expect(fyOf('2027-04-01')).toBe('2027-28'); + }); + + it('buckets dated credits into the right quarters and drops out-of-year dates', () => { + const b = bucketByQuarter( + [ + { date: '2026-04-05', amount: 100 }, + { date: '2026-08-01', amount: 200 }, + { date: '2026-11-30', amount: 300 }, + { date: '2027-01-15', amount: 400 }, + { date: '2026-03-31', amount: 999 }, + { date: '2027-04-01', amount: 999 } + ], + FY + ); + expect(b).toEqual({ 1: 100, 2: 200, 3: 300, 4: 400 }); + }); + + it('spreads an annual estimate without losing rupees to division', () => { + for (const annual of [ + 38_500, + 52_801, + 1, + 999_999 + ]) { + expect(projectQuarterlyCredits(annual, FY).reduce((s, x) => s + x.amount, 0)).toBe(annual); + } + }); + + it('exposes the cumulative liability directly for ad-hoc checks', () => { + expect(cumulativeLiability(50_000, 50_000, general())).toBe(0); + expect(cumulativeLiability(50_001, 50_001, general())).toBe(5_000); + }); +}); diff --git a/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts new file mode 100644 index 000000000..32ee84d6d --- /dev/null +++ b/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts @@ -0,0 +1,1133 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * TDS on deposit interest, and the effect of a payee's declaration on it. + * + * The governing law changed on 01 April 2026. Up to FY 2025-26 the charge sat in + * section 194A of the Income-tax Act 1961 and the declaration was Form 15G or + * 15H; from FY 2026-27 the charge is section 393(1) of the Income Tax Act 2025 + * and a single Form 121 replaces both. Thresholds and rates carried across + * unchanged. Every provision reference lives in `FY_TAX_CONFIG` so a screen or a + * return always quotes the law that applied to the year it is showing. + * + * Two settlement modes, because deposit products run both: + * + * QUARTERLY — deduct at each quarter end, alongside the quarterly return cycle. + * ANNUAL — one settlement at financial year end, for products that post + * interest annually. + * + * The mode follows the product's interest-posting frequency, never a customer + * preference: the charge falls when interest is credited or paid, whichever is + * earlier, so the branch has no discretion to defer. See `tdsModeFor`. + * + * Both modes are built from one year-to-date liability ladder, so for an + * unchanging customer profile they add up to exactly the same rupee figure. + * `reconcile` reports the single case where they legitimately do not. + */ + +/** + * Which Act governs a financial year. + * + * The Income-tax Act 1961 gave way to the Income Tax Act 2025 with effect from + * 01 April 2026, and the change is not cosmetic for this module: the two + * declaration forms merged into one and the eligibility test changed with them. + * A book that spans the changeover has to apply the right rules to each year, + * which is why the regime is carried per year rather than assumed. + */ +export type TaxRegime = 'ITA_1961' | 'ITA_2025'; + +/** + * The declaration a payee files to stop tax being deducted. + * + * Under the 1961 Act there were two, split by age: 15G for the under-60s and 15H + * for senior citizens. From FY 2026-27 both were replaced by a single Form 121 + * open to residents of any age. + */ +export type DeclarationForm = '15G' | '15H' | '121'; + +/** + * Retained for the screens, which still speak of the 15G/15H split when showing + * a pre-2026 year. + */ +export type Form15Kind = '15G' | '15H'; + +export type TdsMode = 'QUARTERLY' | 'ANNUAL'; +export type Quarter = 1 | 2 | 3 | 4; + +/** + * Rates, limits and statutory references for one financial year. + * + * Every figure here moves with the annual Act, so it is table-driven rather than + * inlined at the call sites — a new year is a new entry, not a code change. The + * citations travel with the figures for the same reason: the screens and the + * returns must quote the provision that actually applied to the year on display. + */ +export interface FyTaxConfig { + /** Canonical label, e.g. '2026-27'. */ + fy: string; + regime: TaxRegime; + /** The provision charging TDS on interest, for labels and audit trails. */ + interestTdsSection: string; + /** The provision granting the below-threshold exemption. */ + thresholdSection: string; + /** The provision charging the higher rate where PAN is not furnished. */ + noPanSection: string; + /** The provision on consequences of failing to deduct or pay. */ + defaultSection: string; + /** The declaration form in force. */ + declarationForm: DeclarationForm; + /** The rule the declaration is made under. */ + declarationRule: string; + /** Provision the declaration itself is filed under. */ + declarationSection: string; + /** The quarterly TDS return the deduction is reported in. */ + tdsStatementForm: string; + /** Rule governing that return. */ + tdsStatementRule: string; + /** The certificate issued to the deductee. */ + certificateForm: string; + /** How often the payer uploads the consolidated declaration statement. */ + declarationStatementFrequency: 'MONTHLY' | 'QUARTERLY'; + /** + * Day of the following month or quarter by which that statement is due. + * Under the 2025 Act the upload became monthly, due on the 7th. + */ + declarationStatementDueDay: number; + /** Deduction trigger for a payee below 60. */ + thresholdGeneral: number; + /** Deduction trigger for a senior citizen — 60 at any time during the year. */ + thresholdSenior: number; + /** Rate where a valid PAN is on record. */ + rate: number; + /** Higher rate where PAN is absent or invalid (section 206AA). */ + rateNoPan: number; + /** + * Basic exemption limit. Under the 1961 Act a 15G declarant's estimated income + * *and* their aggregate interest both had to stay within it. + */ + basicExemption: number; + /** + * Total income up to which rebate leaves tax at nil. This is the only ceiling + * for a 15H declarant, and from FY 2026-27 the only ceiling for anyone. + */ + nilTaxCeiling: number; + /** + * Multiple to round each cumulative liability to. 1 = nearest rupee, which is + * what deposit systems generally deduct at. + */ + roundTo: number; + /** Failure to deduct — interest per month or part thereof. */ + lateDeductionRate: number; + /** Deducted but not deposited — interest per month or part thereof. */ + lateDepositRate: number; +} + +/** + * Statutory figures for the years this book covers, on the new tax regime. + * + * For a customer who has opted out onto the old regime, `basicExemption` and + * `nilTaxCeiling` are lower and vary by age; pass an override through + * `TdsContext.config`. + * + * Thresholds and rates did not change at the 2026 changeover — ₹50,000, + * ₹1,00,000 for senior citizens, 10%, and 20% without PAN carried straight + * across. What changed is the declaration and the return it is reported in. + */ +export const FY_TAX_CONFIG: Record = { + '2025-26': { + fy: '2025-26', + regime: 'ITA_1961', + interestTdsSection: 'section 194A', + thresholdSection: 'section 194A(3)(i)', + noPanSection: 'section 206AA', + defaultSection: 'section 201(1A)', + declarationForm: '15G', + declarationRule: 'Rule 29C', + declarationSection: 'section 197A', + tdsStatementForm: 'Form 26Q', + tdsStatementRule: 'Rule 31A', + certificateForm: 'Form 16A', + declarationStatementFrequency: 'QUARTERLY', + declarationStatementDueDay: 15, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + }, + '2026-27': { + fy: '2026-27', + regime: 'ITA_2025', + // Old section 194A was folded into the omnibus TDS section of the 2025 Act. + interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]', + thresholdSection: 'section 393(4) [Table Sl. 7]', + // 206AA and 206CC merged here. The reduced 5% limb applies only to Sl. 8(ii) + // and 8(v), so interest stays at 20% without PAN. + noPanSection: 'section 397(2)', + defaultSection: 'section 398', + declarationForm: '121', + declarationRule: 'Rule 211 of the Income-tax Rules, 2026', + declarationSection: 'section 393(6)', + tdsStatementForm: 'Form 140', + tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026', + certificateForm: 'Form 131', + // The consolidated statement of declarations received moved from a quarterly + // upload to a monthly one, due on the 7th of the following month. + declarationStatementFrequency: 'MONTHLY', + declarationStatementDueDay: 7, + thresholdGeneral: 50_000, + thresholdSenior: 100_000, + rate: 0.1, + rateNoPan: 0.2, + basicExemption: 400_000, + nilTaxCeiling: 1_200_000, + roundTo: 1, + lateDeductionRate: 0.01, + lateDepositRate: 0.015 + } +}; + +// ─────────────────────── Unique Identification Number ─────────────────────── + +/** + * A Form 121 UIN is 26 characters: + * + * D + 9 numeric digits + 6 digits of the tax year + the payer's 10-character TAN + * + * The payer allots one to every declaration it accepts and carries it into both + * the monthly consolidated statement and the deductee annexure of the quarterly + * return, which is how the department ties a nil-deduction back to a declaration. + */ +export const UIN_LENGTH = 26; + +/** The 6-digit tax-year component of a UIN: start year and end year, e.g. 202627. */ +export function uinTaxYearPart(fy: string): string { + const year = normaliseFy(fy); + return `${year.slice(0, 4)}${year.slice(5, 7)}`; +} + +/** + * Builds a UIN from its parts. + * + * `sequence` is the payer's own running number for the year; it is zero-padded to + * nine digits rather than randomised so a branch can reconcile its register + * against the portal by eye, and so the same declaration always yields the same + * UIN on a re-run. + */ +export function buildUin(sequence: number, fy: string, tan: string): string { + const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0'); + const normalisedTan = (tan || '').toUpperCase().replace(/[^A-Z0-9]/g, '').padEnd(10, 'X').slice(0, 10); + return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`; +} + +export interface UinParts { + sequence: number; + fy: string; + tan: string; +} + +/** Splits a UIN back into its parts, or null if it is not well formed. */ +export function parseUin(uin: string): UinParts | null { + const u = (uin || '').toUpperCase(); + if (!/^D\d{9}\d{6}[A-Z0-9]{10}$/.test(u)) { + return null; + } + const startYear = Number(u.slice(10, 14)); + return { + sequence: Number(u.slice(1, 10)), + fy: `${startYear}-${u.slice(14, 16)}`, + tan: u.slice(16) + }; +} + +export function isWellFormedUin(uin: string): boolean { + return parseUin(uin) !== null; +} + +/** + * When the payer must have uploaded the consolidated statement covering a + * declaration accepted on the given date. + * + * Under the 2025 Act the upload is monthly and falls due on the 7th of the + * following month. Under the 1961 Act it ran with the quarter. + */ +export function declarationStatementDueDate(acceptedOn: string, fy: string): string { + const cfg = FY_TAX_CONFIG[normaliseFy(fy)]; + if (!cfg) { + throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`); + } + const day = `${cfg.declarationStatementDueDay}`.padStart(2, '0'); + + if (cfg.declarationStatementFrequency === 'MONTHLY') { + const year = Number(acceptedOn.slice(0, 4)); + const month = Number(acceptedOn.slice(5, 7)); + const nextYear = month === 12 ? year + 1 : year; + const nextMonth = month === 12 ? 1 : month + 1; + return `${nextYear}-${`${nextMonth}`.padStart(2, '0')}-${day}`; + } + + // Quarterly: the month after the quarter in which it was accepted, except the + // last quarter, which the 1961 Act ran to 30 April. + const quarter = quarterOf(acceptedOn); + const start = Number(normaliseFy(fy).slice(0, 4)); + switch (quarter) { + case 1: + return `${start}-07-${day}`; + case 2: + return `${start}-10-${day}`; + case 3: + return `${start + 1}-01-${day}`; + default: + return `${start + 1}-04-30`; + } +} + +/** The declaration form in force for a year. */ +export function declarationFormFor(fy: string, dateOfBirth?: string): DeclarationForm { + const cfg = FY_TAX_CONFIG[normaliseFy(fy)]; + if (!cfg) { + throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`); + } + if (cfg.regime === 'ITA_2025') { + // One form for everybody, whatever their age. + return '121'; + } + return dateOfBirth && isSeniorInFy(dateOfBirth, fy) ? '15H' : '15G'; +} + +/** One interest credit posted to a deposit. */ +export interface InterestCredit { + /** ISO date the interest was paid or credited — whichever came first. */ + date: string; + amount: number; + accountNo?: string; +} + +/** + * A Form 121 declaration on the branch's record. + * + * `acceptedOn` is the operative date: interest credited on or after it is paid + * without deduction. Interest credited before it was already liable and stays + * deducted. + */ +export interface Declaration { + kind: Form15Kind; + acceptedOn: string; + fy: string; + /** + * Unique Identification Number the branch allots on acceptance, carried into + * the consolidated statement of declarations filed with the department. + */ + uin?: string; +} + +export interface TdsContext { + /** 'FY 2026-27' and '2026-27' are both accepted. */ + fy: string; + dateOfBirth: string; + pan: string; + /** A declaration is only available to a resident payee. */ + resident?: boolean; + declaration?: Declaration | null; + /** Overrides the FY table — used for old-regime customers. */ + config?: Partial; +} + +// ───────────────────────── Financial year plumbing ───────────────────────── + +/** Strips the 'FY ' prefix the screens use and validates the shape. */ +export function normaliseFy(fy: string): string { + const m = /(\d{4})-(\d{2,4})/.exec((fy || '').trim()); + if (!m) { + throw new Error(`Unrecognised financial year: "${fy}"`); + } + const start = Number(m[1]); + const endShort = m[2].length === 4 ? m[2].slice(2) : m[2]; + return `${start}-${endShort}`; +} + +export function fyStart(fy: string): string { + return `${normaliseFy(fy).slice(0, 4)}-04-01`; +} + +export function fyEnd(fy: string): string { + return `${Number(normaliseFy(fy).slice(0, 4)) + 1}-03-31`; +} + +/** + * The financial year an ISO date belongs to, as '2026-27'. + * + * April opens the year, so January to March belong to the year that started in + * the previous calendar year. + */ +export function fyOf(isoDate: string): string { + const year = Number(isoDate.slice(0, 4)); + const month = Number(isoDate.slice(5, 7)); + const start = month >= 4 ? year : year - 1; + return `${start}-${`${(start + 1) % 100}`.padStart(2, '0')}`; +} + +export const QUARTER_LABEL: Record = { + 1: 'Q1 (Apr–Jun)', + 2: 'Q2 (Jul–Sep)', + 3: 'Q3 (Oct–Dec)', + 4: 'Q4 (Jan–Mar)' +}; + +/** Statutory due date for the quarterly TDS return. */ +export const QUARTER_DUE_DATE: Record = { + 1: '31 Jul', + 2: '31 Oct', + 3: '31 Jan', + 4: '31 May' +}; + +/** Which quarter of the Indian financial year an ISO date falls in. */ +export function quarterOf(isoDate: string): Quarter { + const month = Number(isoDate.slice(5, 7)); + if (month >= 4 && month <= 6) { + return 1; + } + if (month >= 7 && month <= 9) { + return 2; + } + if (month >= 10 && month <= 12) { + return 3; + } + return 4; +} + +/** Last day of the given quarter of the given financial year. */ +export function quarterEnd(quarter: Quarter, fy: string): string { + const start = Number(normaliseFy(fy).slice(0, 4)); + switch (quarter) { + case 1: + return `${start}-06-30`; + case 2: + return `${start}-09-30`; + case 3: + return `${start}-12-31`; + default: + return `${start + 1}-03-31`; + } +} + +/** + * Age the customer attains during the financial year. + * + * Deliberately not age as at today. For the senior-citizen threshold the + * test is whether the payee is 60 at any time during the year, so a customer + * turning 60 in March gets the senior limit from the April credit onwards. + */ +export function ageInFy(dateOfBirth: string, fy: string): number { + if (!dateOfBirth) { + return 0; + } + const born = new Date(`${dateOfBirth}T00:00:00Z`); + const asAt = new Date(`${fyEnd(fy)}T00:00:00Z`); + let age = asAt.getUTCFullYear() - born.getUTCFullYear(); + const m = asAt.getUTCMonth() - born.getUTCMonth(); + if (m < 0 || (m === 0 && asAt.getUTCDate() < born.getUTCDate())) { + age--; + } + return age; +} + +export function isSeniorInFy(dateOfBirth: string, fy: string): boolean { + return ageInFy(dateOfBirth, fy) >= 60; +} + +/** 15H once the customer is 60 during the year, 15G otherwise. */ +export function formKindFor(dateOfBirth: string, fy: string): Form15Kind { + return isSeniorInFy(dateOfBirth, fy) ? '15H' : '15G'; +} + +// ───────────────────────────── PAN validation ────────────────────────────── + +/** + * The fourth character of a PAN encodes the holder type. Only an individual (P) + * or a HUF (H) can give a Form 121 declaration; the rest are entities. + */ +const PAN_HOLDER_TYPES = 'ABCFGHJLPT'; + +export function isWellFormedPan(pan: string): boolean { + const p = (pan || '').toUpperCase(); + return /^[A-Z]{5}[0-9]{4}[A-Z]$/.test(p) && PAN_HOLDER_TYPES.includes(p[3]); +} + +export function isIndividualPan(pan: string): boolean { + const p = (pan || '').toUpperCase(); + return isWellFormedPan(p) && (p[3] === 'P' || p[3] === 'H'); +} + +// ──────────────────── Which accounts, and whose PAN ──────────────────────── + +/** + * The shape this module needs from a deposit account. Deliberately loose so it + * can be fed straight from a Fineract account payload. + */ +export interface TdsAccountRef { + id?: number; + accountNo?: string; + /** Fineract `depositType.code`, e.g. 'depositAccountType.fixedDeposit'. */ + depositTypeCode?: string; + /** RBI account status captured on the stepper: SINGLE, JOINT, MINOR, … */ + accountStatus?: string; + /** The account holder for a single-name account. */ + clientId?: number | null; + /** First-named holder on a joint account. */ + primaryClientId?: number | null; + /** Guardian operating a minor's account. */ + guardianClientId?: number | null; + jointHolders?: { clientId: number }[]; + /** Fineract `interestPostingPeriodType` — object, code or id. */ + interestPostingPeriodType?: { id?: number; code?: string; value?: string } | string | number | null; +} + +/** An account together with the interest credited to it. */ +export interface TdsAccountInput extends TdsAccountRef { + credits: InterestCredit[]; +} + +/** + * Whether interest on this account attracts TDS at all. + * + * The charge bites on *time deposits* under either Act. Interest on a savings + * bank account is + * outside it — the customer claims the 80TTA / 80TTB deduction instead — so + * savings interest must be kept out of the aggregate as well as out of the + * deduction. Folding it in would push customers over the trigger who were never + * meant to cross it. + */ +export function isTdsApplicable(account: TdsAccountRef): boolean { + const code = (account.depositTypeCode || '').toLowerCase(); + return code.includes('fixeddeposit') || code.includes('recurringdeposit'); +} + +/** + * The client in whose PAN the tax must be deducted. + * + * A joint deposit is reported against the first-named holder: the bank's + * obligation runs to one PAN, and the return carries one deductee for the account. + * Any `sharePercentage` on the account governs operational matters — signing + * authority, closure proceeds, insurance cover — and must not be used to split + * interest for tax, which would produce two deductees for one deposit. + * + * A minor's interest is clubbed into the guardian's income under section + * 64(1A), so the deduction follows the guardian. + */ +export function tdsDeducteeOf(account: TdsAccountRef): number | null { + const status = (account.accountStatus || '').toUpperCase(); + if (status === 'JOINT') { + return account.primaryClientId ?? account.clientId ?? null; + } + if (status === 'MINOR') { + return account.guardianClientId ?? account.clientId ?? null; + } + return account.clientId ?? account.primaryClientId ?? null; +} + +/** + * When the branch must settle tax on this account. + * + * Driven by the product's interest-posting frequency, because the charge falls + * when interest is credited or paid, whichever is earlier. A customer cannot + * elect to defer it — doing so puts the bank in default and attracts interest for + * every month or part of a month of delay. A customer who wants no deduction has + * two lawful routes: a declaration if they qualify, or a deposit product that + * posts interest annually. + * + * Monthly and half-yearly posting both settle on the quarterly cycle, which is + * what the quarterly return runs on. + */ +export function tdsModeFor(account: TdsAccountRef): TdsMode { + const raw = account.interestPostingPeriodType; + const text = typeof raw === 'object' && raw !== null ? `${raw.code ?? ''} ${raw.value ?? ''}` : `${raw ?? ''}`; + const s = text.toLowerCase(); + // 'biannual' contains 'annual' — test the compound word first or half-yearly + // products would silently settle once a year. + if (s.includes('biannual') || s.includes('bi-annual') || s.includes('semiannual')) { + return 'QUARTERLY'; + } + if (s.includes('annual')) { + return 'ANNUAL'; + } + return 'QUARTERLY'; +} + +/** + * Every interest credit that counts towards one customer's deduction aggregate. + * + * This is the one place rules 8 to 12 meet: savings interest is dropped, the + * deductee is resolved per account, and only the selected year is kept. The + * aggregate spans every branch, because a bank on core banking computes the + * threshold for the bank as a whole rather than branch by branch. + */ +export function collectTdsCredits(clientId: number, accounts: TdsAccountInput[], fy: string): InterestCredit[] { + const year = normaliseFy(fy); + const out: InterestCredit[] = []; + for (const account of accounts) { + if (!isTdsApplicable(account) || tdsDeducteeOf(account) !== clientId) { + continue; + } + for (const c of account.credits) { + if (fyOf(c.date) === year) { + out.push({ ...c, accountNo: c.accountNo ?? account.accountNo }); + } + } + } + return out.sort((a, b) => a.date.localeCompare(b.date)); +} + +// ─────────────────────────────── Eligibility ─────────────────────────────── + +export interface EligibilityInput { + /** Aggregate interest the bank expects to credit this year. */ + estimatedInterest: number; + /** The customer's estimated total income for the year, interest included. */ + estimatedIncome: number; + /** + * Whether the income figure is actually on record. The branch cannot compute + * it — income from outside the bank is only known if the customer declares + * it — so when it is missing the income limbs are reported as unassessed + * rather than silently passed. + */ + incomeOnRecord?: boolean; +} + +export interface EligibilityResult { + eligible: boolean; + /** The form actually in force for the year — '121' from FY 2026-27. */ + form: DeclarationForm; + /** + * The 15G/15H label, for screens showing a pre-2026 year. From FY 2026-27 the + * forms are merged, so this only reflects which threshold band the payee is in. + */ + kind: Form15Kind; + /** Why the declaration cannot be accepted, in the branch's words. */ + reasons: string[]; + /** Tests that could not be run because a figure was missing. */ + unassessed: string[]; +} + +/** + * Whether the payee's declaration can be accepted. + * + * The test depends on which Act governs the year, and the difference is real: + * + * Up to FY 2025-26 (1961 Act) — two forms, split by age. + * 15G, under 60: estimated total income must be within the basic exemption + * limit *and* aggregate interest must also be within it. Both limbs. + * 15H, 60 and over: the only test is a nil tax liability; there is no + * ceiling on the interest figure, so a senior citizen with several lakh + * of interest may still declare if rebate brings the tax to nil. + * + * From FY 2026-27 (2025 Act) — one Form 121 for residents of any age, and a + * single test: estimated tax on total income for the year must be nil. The + * interest limb is gone, so a young depositor with large interest but no + * liability is now eligible where a 15G would have failed them. + * + * No declaration is valid without PAN under either regime, and none is open to a + * non-resident. Form 121 tightened this further: the 15G/15H forms accepted PAN + * or Aadhaar, whereas Form 121 takes PAN only. + */ +export function checkEligibility(ctx: TdsContext, input: EligibilityInput): EligibilityResult { + const cfg = resolveConfig(ctx); + const form = declarationFormFor(ctx.fy, ctx.dateOfBirth); + const kind = formKindFor(ctx.dateOfBirth, ctx.fy); + const reasons: string[] = []; + const unassessed: string[] = []; + const label = form === '121' ? 'Form 121' : `Form ${form}`; + + if (!isWellFormedPan(ctx.pan)) { + reasons.push( + `PAN is missing or malformed — the declaration is void without a valid PAN (${cfg.declarationRule}).` + + (cfg.regime === 'ITA_2025' ? ' Form 121 accepts PAN only; Aadhaar is not a substitute.' : '') + ); + } else if (!isIndividualPan(ctx.pan)) { + reasons.push(`PAN belongs to an entity, not an individual or HUF — ${label} is not available to it.`); + } + + if (ctx.resident === false) { + reasons.push('Declaration is available only to a resident payee.'); + } + + const rupees = (n: number) => `₹${n.toLocaleString('en-IN')}`; + const incomeKnown = input.incomeOnRecord !== false; + + // The interest limb only ever applied to a 15G, and only under the 1961 Act. + const hasInterestLimb = cfg.regime === 'ITA_1961' && form === '15G'; + const incomeCeiling = hasInterestLimb ? cfg.basicExemption : cfg.nilTaxCeiling; + + if (!incomeKnown) { + unassessed.push( + `Estimated total income is not on record — the nil-liability test for ${label} was not run. The branch ` + + `cannot compute income earned outside the bank; the payee must declare it.` + ); + } else if (input.estimatedIncome > incomeCeiling) { + reasons.push( + hasInterestLimb + ? `Estimated total income ${rupees(input.estimatedIncome)} exceeds the basic exemption limit of ` + + `${rupees(cfg.basicExemption)}.` + : `Estimated total income ${rupees(input.estimatedIncome)} leaves a tax liability — ${label} requires the ` + + `liability for the year to be nil.` + ); + } + + if (hasInterestLimb && input.estimatedInterest > cfg.basicExemption) { + // The bank knows this figure without asking, so it is always assessable. + reasons.push( + `Aggregate interest ${rupees(input.estimatedInterest)} exceeds the basic exemption limit — a 15G declarant ` + + `must clear this limb as well as the income limb.` + ); + } + + return { eligible: reasons.length === 0, form, kind, reasons, unassessed }; +} + +// ──────────────────────── The year-to-date liability ──────────────────────── + +function resolveConfig(ctx: TdsContext): FyTaxConfig { + const fy = normaliseFy(ctx.fy); + const base = FY_TAX_CONFIG[fy]; + if (!base) { + throw new Error(`No tax configuration for FY ${fy}`); + } + return { ...base, ...ctx.config }; +} + +/** Rounds to the nearest multiple of `to`. */ +function roundToMultiple(value: number, to: number): number { + return to <= 1 ? Math.round(value) : Math.round(value / to) * to; +} + +/** + * TDS on the interest credited so far this year. + * + * The threshold is a *trigger*, not an allowance. Once aggregate interest for + * the year exceeds it, the whole of that interest becomes liable — not merely + * the excess. ₹52,000 of interest attracts 10% of ₹52,000, i.e. ₹5,200, and not + * 10% of the ₹2,000 above the line. + * + * Rounding is applied here, to the cumulative figure. That placement is what + * makes the quarterly schedule add up — see `quarterlySchedule`. + */ +export function cumulativeLiability(cumInterest: number, cumLiableInterest: number, ctx: TdsContext): number { + const cfg = resolveConfig(ctx); + const threshold = isSeniorInFy(ctx.dateOfBirth, ctx.fy) ? cfg.thresholdSenior : cfg.thresholdGeneral; + if (cumInterest <= threshold) { + return 0; + } + const rate = isWellFormedPan(ctx.pan) ? cfg.rate : cfg.rateNoPan; + return roundToMultiple(Math.max(0, cumLiableInterest) * rate, cfg.roundTo); +} + +// ────────────────────────────── The schedules ─────────────────────────────── + +export interface TdsQuarterLine { + quarter: Quarter; + label: string; + dueDate: string; + /** Interest credited during the quarter. */ + interest: number; + /** Interest credited from 01 April to the end of this quarter. */ + cumInterest: number; + /** Of that, the part not covered by a declaration. */ + cumLiableInterest: number; + /** Liability on the year to date, before crediting what is already deducted. */ + cumLiability: number; + /** + * Deducted in this quarter — the catch-up over what has already gone. A + * negative figure is an in-year adjustment following an interest reversal. + */ + tds: number; + /** Deducted from 01 April to the end of this quarter. */ + cumTds: number; + /** The aggregate crossed the deduction trigger during this quarter. */ + thresholdCrossedHere: boolean; + /** A declaration was on the branch's record by the end of this quarter. */ + declarationActive: boolean; +} + +export interface TdsSchedule { + mode: TdsMode; + fy: string; + kind: Form15Kind; + senior: boolean; + /** Rate actually applied — 20% where PAN is not on record. */ + rateApplied: number; + threshold: number; + totalInterest: number; + /** Interest not covered by a declaration. */ + liableInterest: number; + /** Interest paid gross because a declaration was on record. */ + shieldedInterest: number; + totalTds: number; + lines: TdsQuarterLine[]; + /** Anything the branch should read before filing. */ + notes: string[]; +} + +function emptyQuarters(): Record { + return { 1: 0, 2: 0, 3: 0, 4: 0 }; +} + +/** + * Buckets dated interest credits into the four quarters of the year. + * + * Credits outside the year are dropped rather than folded into Q4 — a stray + * date is a data error, not a Q4 posting. + */ +export function bucketByQuarter(credits: InterestCredit[], fy: string): Record { + const from = fyStart(fy); + const to = fyEnd(fy); + const out = emptyQuarters(); + for (const c of credits) { + if (c.date >= from && c.date <= to) { + out[quarterOf(c.date)] += c.amount; + } + } + return out; +} + +/** Splits each quarter's interest into shielded and liable halves. */ +function splitByDeclaration( + credits: InterestCredit[], + ctx: TdsContext +): { liable: Record; shielded: Record } { + const from = fyStart(ctx.fy); + const to = fyEnd(ctx.fy); + const decl = ctx.declaration; + const liable = emptyQuarters(); + const shielded = emptyQuarters(); + + for (const c of credits) { + if (c.date < from || c.date > to) { + continue; + } + const q = quarterOf(c.date); + // A declaration shields interest credited on or after the date the branch + // accepted it. It never reaches back over earlier credits. + if (decl && decl.acceptedOn && c.date >= decl.acceptedOn) { + shielded[q] += c.amount; + } else { + liable[q] += c.amount; + } + } + return { liable, shielded }; +} + +interface LadderStep { + quarter: Quarter; + interest: number; + cumInterest: number; + cumLiableInterest: number; + cumLiability: number; + declarationActive: boolean; +} + +/** + * The year-to-date liability at each quarter end. + * + * This single ladder is the source of both schedules. The quarterly mode reads + * every rung; the annual mode reads only the last. That is the whole reason the + * two agree — they are not two calculations, they are two samplings of one. + */ +function buildLadder(credits: InterestCredit[], ctx: TdsContext): LadderStep[] { + const { liable, shielded } = splitByDeclaration(credits, ctx); + const steps: LadderStep[] = []; + let cumInterest = 0; + let cumLiableInterest = 0; + + for (const q of [ + 1, + 2, + 3, + 4 + ] as Quarter[]) { + cumInterest += liable[q] + shielded[q]; + cumLiableInterest += liable[q]; + steps.push({ + quarter: q, + interest: liable[q] + shielded[q], + cumInterest, + cumLiableInterest, + cumLiability: cumulativeLiability(cumInterest, cumLiableInterest, ctx), + // Keyed off the acceptance date rather than off whether shielded interest + // happened to arrive: a quarter carrying only a reversal is still a + // quarter in which the declaration was on the branch's record. + declarationActive: + !!ctx.declaration && !!ctx.declaration.acceptedOn && quarterEnd(q, ctx.fy) >= ctx.declaration.acceptedOn + }); + } + return steps; +} + +function scheduleShell(mode: TdsMode, credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + const cfg = resolveConfig(ctx); + const senior = isSeniorInFy(ctx.dateOfBirth, ctx.fy); + const { liable, shielded } = splitByDeclaration(credits, ctx); + const sum = (r: Record) => r[1] + r[2] + r[3] + r[4]; + return { + mode, + fy: normaliseFy(ctx.fy), + kind: formKindFor(ctx.dateOfBirth, ctx.fy), + senior, + rateApplied: isWellFormedPan(ctx.pan) ? cfg.rate : cfg.rateNoPan, + threshold: senior ? cfg.thresholdSenior : cfg.thresholdGeneral, + totalInterest: sum(liable) + sum(shielded), + liableInterest: sum(liable), + shieldedInterest: sum(shielded), + totalTds: 0, + lines: [], + notes: [] + }; +} + +/** + * Deduct at each quarter end. + * + * Each quarter deducts the difference between the liability on the year to date + * and what has already been deducted: + * + * tds(n) = cumLiability(n) − cumTds(n−1) + * + * Written that way the four quarters telescope: they sum to cumLiability(4) less + * cumLiability(0), which is precisely the liability on the whole year's + * interest. The identity survives rounding because it is the *cumulative* figure + * that gets rounded, so each quarter is a difference of two already rounded + * numbers. Rounding each quarter's own share independently is what makes naive + * implementations drift by a rupee or two a year. + * + * It also gets the threshold right for free. Nothing is deducted while the + * aggregate is under the trigger; in the quarter it is crossed, the full + * year-to-date liability falls due at once. That catch-up looks severe against + * the one quarter's interest, and it is what a branch actually deducts. + */ +export function quarterlySchedule(credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + const out = scheduleShell('QUARTERLY', credits, ctx); + const ladder = buildLadder(credits, ctx); + const decl = ctx.declaration; + + let cumTds = 0; + let frozen = false; + let prevCumInterest = 0; + + for (const step of ladder) { + // Once a declaration is operative the branch stops deducting. It does not + // refund what it has already remitted to the Government either — the + // customer reclaims that in their return, which is why cumTds freezes here + // rather than falling back to the now-lower liability. + if (decl && step.declarationActive) { + frozen = true; + } + + const target = frozen ? Math.max(cumTds, step.cumLiability) : step.cumLiability; + const tds = target - cumTds; + cumTds = target; + + out.lines.push({ + quarter: step.quarter, + label: QUARTER_LABEL[step.quarter], + dueDate: QUARTER_DUE_DATE[step.quarter], + interest: step.interest, + cumInterest: step.cumInterest, + cumLiableInterest: step.cumLiableInterest, + cumLiability: step.cumLiability, + tds, + cumTds, + thresholdCrossedHere: prevCumInterest <= out.threshold && step.cumInterest > out.threshold, + declarationActive: step.declarationActive + }); + prevCumInterest = step.cumInterest; + } + + out.totalTds = cumTds; + + if (decl && decl.acceptedOn && cumTds > 0) { + out.notes.push( + `₹${cumTds.toLocaleString('en-IN')} relates to interest credited before the declaration was accepted on ` + + `${decl.acceptedOn} and has been remitted to the Government. A declaration operates prospectively, so ` + + `the branch cannot reverse it — the customer recovers it in their return if the nil-liability basis holds.` + ); + } + return out; +} + +/** + * Defer to a single settlement at financial year end. + * + * This is the last rung of the same ladder, so with an unchanging profile it + * equals the sum of the quarterly figures by construction. The per-quarter lines + * still carry the interest and the liability as they accrued, with the whole + * deduction landing in Q4 — which is what the branch's Q4 return will show. + */ +export function annualSchedule(credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + const out = scheduleShell('ANNUAL', credits, ctx); + const ladder = buildLadder(credits, ctx); + const final = ladder[ladder.length - 1]; + let prevCumInterest = 0; + + for (const step of ladder) { + const isLast = step.quarter === 4; + out.lines.push({ + quarter: step.quarter, + label: QUARTER_LABEL[step.quarter], + dueDate: QUARTER_DUE_DATE[step.quarter], + interest: step.interest, + cumInterest: step.cumInterest, + cumLiableInterest: step.cumLiableInterest, + cumLiability: step.cumLiability, + tds: isLast ? final.cumLiability : 0, + cumTds: isLast ? final.cumLiability : 0, + thresholdCrossedHere: prevCumInterest <= out.threshold && step.cumInterest > out.threshold, + declarationActive: step.declarationActive + }); + prevCumInterest = step.cumInterest; + } + + out.totalTds = final.cumLiability; + return out; +} + +export function buildSchedule(mode: TdsMode, credits: InterestCredit[], ctx: TdsContext): TdsSchedule { + return mode === 'QUARTERLY' ? quarterlySchedule(credits, ctx) : annualSchedule(credits, ctx); +} + +// ───────────────────────────── Reconciliation ────────────────────────────── + +export interface TdsReconciliation { + quarterlyTotal: number; + annualTotal: number; + /** Quarterly less annual. Positive means the quarterly book deducted more. */ + difference: number; + matched: boolean; + reason: string; +} + +/** + * Checks the two modes against each other for one customer. + * + * They agree to the rupee in every ordinary case, including a declaration taken + * mid-year: because a declaration operates prospectively, the year-end figure + * still recognises the interest credited before it was accepted as liable, and + * the annual book deducts on exactly that. Interest reversals agree too — the + * quarterly book adjusts them in-year. + * + * One case genuinely diverges, and it is not an arithmetic fault. If the + * year-to-date liability *falls* after a declaration has already frozen + * deductions — a large reversal dragging the aggregate back under the deduction + * trigger, say — the quarterly book is holding tax it may no longer collect but + * cannot refund either, while the annual book never deducts it at all. + * + * Worth running as a year-end control: a non-zero difference with no declaration + * on record, or with no fall in liability behind it, is a bug. + */ +export function reconcile(credits: InterestCredit[], ctx: TdsContext): TdsReconciliation { + const q = quarterlySchedule(credits, ctx); + const a = annualSchedule(credits, ctx); + const difference = q.totalTds - a.totalTds; + + let reason = 'Quarterly and annual settlement agree on the year.'; + if (difference !== 0) { + const firstActive = q.lines.find((l) => l.declarationActive); + reason = firstActive + ? `₹${difference.toLocaleString('en-IN')} was deducted before the declaration became operative in ` + + `${QUARTER_LABEL[firstActive.quarter]} and the year-to-date liability has since fallen. The branch ` + + `cannot refund it; the customer recovers it in their return.` + : `Unexplained difference of ₹${difference.toLocaleString('en-IN')} — investigate before filing.`; + } + + return { quarterlyTotal: q.totalTds, annualTotal: a.totalTds, difference, matched: difference === 0, reason }; +} + +// ──────────────────────── Default interest (s.201(1A)) ────────────────────── + +/** + * Months, or parts of a month, between two dates. + * + * Section 201(1A) charges for "every month or part of a month", so any delay at + * all counts as a full month and a fortnight over a month counts as two. + */ +export function monthsOrPartBetween(fromIso: string, toIso: string): number { + if (!fromIso || !toIso || toIso <= fromIso) { + return 0; + } + const [ + y1, + m1, + d1 + ] = fromIso.split('-').map(Number); + const [ + y2, + m2, + d2 + ] = toIso.split('-').map(Number); + let months = (y2 - y1) * 12 + (m2 - m1); + if (d2 > d1) { + months += 1; + } + return Math.max(1, months); +} + +export interface LateInterestResult { + months: number; + rate: number; + interest: number; +} + +/** + * Interest payable by the bank for deducting late, or for depositing late. + * + * `kind` picks the limb: 'DEDUCTION' is 1% a month from the date the tax was + * deductible to the date it was actually deducted; 'DEPOSIT' is 1.5% a month + * from deduction to actual payment. This is the cost the branch carries if it + * settles outside the statutory cycle, and it is what makes deferring at a + * customer's request a bad trade for the bank. + */ +export function lateDeductionInterest( + amount: number, + dueDateIso: string, + actualDateIso: string, + kind: 'DEDUCTION' | 'DEPOSIT', + fy: string +): LateInterestResult { + const cfg = FY_TAX_CONFIG[normaliseFy(fy)]; + if (!cfg) { + throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`); + } + const rate = kind === 'DEDUCTION' ? cfg.lateDeductionRate : cfg.lateDepositRate; + const months = monthsOrPartBetween(dueDateIso, actualDateIso); + return { months, rate, interest: Math.round(amount * rate * months) }; +} + +// ──────────────────────────────── Projection ──────────────────────────────── + +/** + * Spreads a single annual interest estimate evenly across the four quarters. + * + * A convenience for screens that hold only a projected annual figure, as the + * Form 121 register does. The remainder lands in Q4 so the four parts add back + * to the estimate exactly rather than losing rupees to division. + */ +export function projectQuarterlyCredits(annualInterest: number, fy: string): InterestCredit[] { + const startYear = Number(normaliseFy(fy).slice(0, 4)); + const base = Math.floor(annualInterest / 4); + const dates = [ + `${startYear}-06-30`, + `${startYear}-09-30`, + `${startYear}-12-31`, + `${startYear + 1}-03-31` + ]; + return dates.map((date, i) => ({ date, amount: i === 3 ? annualInterest - base * 3 : base })); +} diff --git a/src/app/shared/search-select/search-select.component.ts b/src/app/shared/search-select/search-select.component.ts new file mode 100644 index 000000000..2a7e27558 --- /dev/null +++ b/src/app/shared/search-select/search-select.component.ts @@ -0,0 +1,236 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export interface SearchSelectOption { + value: string; + label: string; + /** What typed text is matched against — defaults to `label` when omitted, but a caller can + * pass a richer string (e.g. account no + customer name + branch) so a search hits fields + * that aren't all visible in the collapsed label. */ + searchText?: string; +} + +/** + * Drop-in replacement for a plain ` + @if (open) { +
    + @for (o of filteredOptions; track o.value) { +
  • + {{ o.label }} +
  • + } @empty { +
  • No matches
  • + } +
+ } +
+ `, + styles: [ + ` + /* Angular view encapsulation stops the host page's shared styles from reaching inside this + * component's own template, so its look is re-declared here rather than inherited. */ + :host { + display: block; + width: 100%; + } + .inv-search-select { + position: relative; + width: 100%; + } + .inv-search-select__input { + width: 100%; + height: 36px; + padding: 0 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + color: #0d1b2a; + font-family: inherit; + background: #fff; + box-sizing: border-box; + } + /* position: fixed, anchored via JS to the input's own screen coords (see + * updateListPosition()) rather than absolute inside this component — most of this app's + * cards use overflow: hidden for rounded corners, which would otherwise clip a dropdown + * that extends past the card's edge. */ + .inv-search-select__list { + position: fixed; + z-index: 2000; + max-height: 260px; + overflow-y: auto; + margin: 0; + padding: 4px; + list-style: none; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18); + } + .inv-search-select__option { + padding: 8px 10px; + border-radius: 6px; + font-size: 13px; + color: #0d1b2a; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .inv-search-select__option:hover, + .inv-search-select__option--active { + background: #eef2ff; + } + .inv-search-select__empty { + padding: 8px 10px; + font-size: 12px; + color: #94a3b8; + } + ` + ] +}) +export class SearchSelectComponent implements ControlValueAccessor { + @Input() options: SearchSelectOption[] = []; + @Input() placeholder = 'Search…'; + + @ViewChild('inputEl') private inputEl?: ElementRef; + + value = ''; + query = ''; + open = false; + disabled = false; + + listTop = 0; + listLeft = 0; + listWidth = 0; + + private onChangeFn: (v: string) => void = () => {}; + onTouched: () => void = () => {}; + + get filteredOptions(): SearchSelectOption[] { + const q = this.query.trim().toLowerCase(); + if (!q) { + return this.options; + } + return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q)); + } + + private labelFor(value: string): string { + return this.options.find((o) => o.value === value)?.label ?? ''; + } + + writeValue(v: string): void { + this.value = v ?? ''; + this.query = this.labelFor(this.value); + } + + registerOnChange(fn: (v: string) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + /** Anchors the fixed-position list to the input's current on-screen box. */ + private updateListPosition(): void { + const el = this.inputEl?.nativeElement; + if (!el) { + return; + } + const rect = el.getBoundingClientRect(); + this.listTop = rect.bottom + 4; + this.listLeft = rect.left; + this.listWidth = rect.width; + } + + @HostListener('window:scroll') + @HostListener('window:resize') + onWindowChange(): void { + if (this.open) { + this.updateListPosition(); + } + } + + onInput(event: Event): void { + this.query = (event.target as HTMLInputElement).value; + this.open = true; + this.updateListPosition(); + } + + onFocus(): void { + this.open = true; + this.updateListPosition(); + } + + onBlur(): void { + this.open = false; + this.onTouched(); + // Discard any uncommitted typed text — only a clicked option ever changes `value`. + this.query = this.labelFor(this.value); + } + + selectOption(o: SearchSelectOption): void { + this.value = o.value; + this.query = o.label; + this.open = false; + this.onChangeFn(this.value); + this.onTouched(); + } +} diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html new file mode 100644 index 000000000..80264c9de --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html @@ -0,0 +1,210 @@ + + +
+ +
+
+ + {{ 'labels.inputs.From Date' | translate }} + + + + + + + {{ 'labels.inputs.To Date' | translate }} + + + + + + + + @if (isFiltered) { + + } + + + + +
+
+ + +
+ +
+
+
+ account_balance +
+
+
LincPay Co-operative Bank Ltd.
+
Share Account Passbook
+
+
+
+
SHARE HOLDING STATEMENT
+
As on: {{ maxDate | dateFormat }}
+
+
+ + + + + +
+
+
Approved Shares
+
{{ totalApprovedShares }}
+
+
+
+
Share Value
+
+ {{ currentMarketPrice | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} +
+
+
+
+
Current Value
+
{{ currentValue | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
+
+
+
+
Total Transactions
+
{{ filteredTransactions.length }}
+
+
+ + + @if (filteredTransactions.length > 0) { + + + + + + + + + + + + + + + + @for (txn of filteredTransactions; track txn.id; let i = $index) { + + + + + + + + + + + + } + + + + + + + + +
#DateDescriptionRef No.Shares PurchasedShares RedeemedShare BalanceAmount (₹)Resolution No. / Meeting
{{ i + 1 }}{{ txn.purchasedDate | dateFormat }} + {{ txn.type.value }} ({{ txn.status.value }}) + {{ txn.id }} + @if (!isRedeem(txn)) { + {{ txn.numberOfShares }} + } + + @if (isRedeem(txn)) { + {{ txn.numberOfShares }} + } + {{ txn.runningBalance }} + {{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} + + @if (isRedeem(txn)) { + {{ txn.resolutionNumber || '—' }} + @if (txn.meetingDate) { + ({{ txn.meetingDate | dateFormat }}) + } + } @else { + — + } +
Closing Share Balance{{ totalApprovedShares }}
+ } @else { +
+ inbox +

No transactions found for the selected date range.

+
+ } + + + +
+
diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss new file mode 100644 index 000000000..cfe41963e --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.scss @@ -0,0 +1,462 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +// ─── Variables ────────────────────────────────────────────────────────────── +$bank-primary: #1a3a6b; // deep navy +$bank-accent: #c8a951; // gold trim +$debit-color: #c0392b; +$credit-color: #1a7a3f; +$balance-color: #1a3a6b; +$border-color: #d0d7e3; +$row-alt: #f4f7fb; + +// ─── Container ────────────────────────────────────────────────────────────── +.passbook-container { + padding: 16px; + background: #eef1f7; + min-height: 100%; +} + +// ─── Filter Bar ───────────────────────────────────────────────────────────── +.passbook-filter-bar { + margin-bottom: 16px; + background: #fff; + border-radius: 8px; + padding: 12px 16px; + border: 1px solid $border-color; +} + +.passbook-filter-form { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.passbook-date-field { + width: 180px; +} + +.passbook-filter-spacer { + flex: 1; +} + +// ─── Statement Wrapper ─────────────────────────────────────────────────────── +.statement-wrapper { + background: #fff; + border: 1px solid $border-color; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 12px rgb(0 0 0 / 8%); + font-family: 'Segoe UI', Arial, sans-serif; +} + +// ─── Bank Header ───────────────────────────────────────────────────────────── +.bank-header { + background: $bank-primary; + padding: 18px 24px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 4px solid $bank-accent; +} + +.bank-logo-area { + display: flex; + align-items: center; + gap: 14px; +} + +.bank-logo-circle { + width: 52px; + height: 52px; + border-radius: 50%; + background: $bank-accent; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + color: $bank-primary; + font-size: 28px; + width: 28px; + height: 28px; + } +} + +.bank-name-block { + .bank-title { + color: #fff; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.3px; + } + + .bank-subtitle { + color: $bank-accent; + font-size: 11px; + font-weight: 500; + letter-spacing: 1.5px; + text-transform: uppercase; + margin-top: 2px; + } +} + +.statement-label { + text-align: right; + + .stmt-label-text { + color: $bank-accent; + font-size: 13px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + } + + .stmt-period { + color: rgb(255 255 255 / 75%); + font-size: 11px; + margin-top: 4px; + } +} + +// ─── Account Details Band ──────────────────────────────────────────────────── +.account-details-band { + background: #f0f3f9; + border-bottom: 1px solid $border-color; + padding: 14px 24px; +} + +.account-details-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px 24px; +} + +.detail-block { + .detail-label { + font-size: 9px; + font-weight: 700; + color: #7a8aaa; + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 3px; + } + + .detail-value { + font-size: 13px; + color: #222; + + &.bold { + font-weight: 600; + } + + &.mono { + font-family: 'Courier New', monospace; + letter-spacing: 1px; + } + + &.status-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + background: #eee; + color: #555; + + &.active { + background: #d4edda; + color: #155724; + } + } + } +} + +// ─── Balance Summary Strip ─────────────────────────────────────────────────── +.balance-summary-strip { + display: flex; + align-items: stretch; + background: $bank-primary; + padding: 0; +} + +.balance-box { + flex: 1; + padding: 12px 20px; + text-align: center; + + .balance-box-label { + font-size: 10px; + font-weight: 600; + color: rgb(255 255 255 / 65%); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 4px; + } + + .balance-box-value { + font-size: 16px; + font-weight: 700; + color: $bank-accent; + } +} + +.balance-divider { + width: 1px; + background: rgb(255 255 255 / 15%); + margin: 8px 0; +} + +// ─── Transactions Table ─────────────────────────────────────────────────────── +.statement-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + thead tr { + background: #e8edf5; + border-top: 1px solid $border-color; + border-bottom: 2px solid $bank-primary; + } + + thead th { + padding: 9px 10px; + font-size: 11px; + font-weight: 700; + color: $bank-primary; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; + } + + tbody tr { + border-bottom: 1px solid #e8ecf3; + transition: background 0.1s; + + &:nth-child(even) { + background: $row-alt; + } + + &:hover { + background: #dce6f5; + } + } + + tbody td { + padding: 7px 10px; + vertical-align: middle; + color: #333; + } + + tfoot .total-row { + background: #e8edf5; + border-top: 2px solid $bank-primary; + + td { + padding: 9px 10px; + } + } +} + +// ─── Column widths ──────────────────────────────────────────────────────────── +.col-sr { + width: 38px; +} + +.col-date { + width: 100px; + white-space: nowrap; +} + +.col-desc { + min-width: 140px; +} + +.col-ref { + width: 70px; +} + +.col-amount { + width: 100px; +} + +.col-balance { + width: 90px; +} + +// ─── Amount colors ──────────────────────────────────────────────────────────── +.debit-amount { + color: $debit-color; + font-weight: 500; +} + +.credit-amount { + color: $credit-color; + font-weight: 500; +} + +.balance-amount { + color: $balance-color; + font-weight: 600; + font-family: 'Courier New', monospace; +} + +.total-label { + font-size: 12px; + font-weight: 700; + color: $bank-primary; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.closing-balance { + font-size: 14px; + font-weight: 700; + color: $bank-primary; + font-family: 'Courier New', monospace; +} + +// ─── Badges & misc ──────────────────────────────────────────────────────────── +.txn-desc { + font-weight: 500; +} + +.text-right { + text-align: right; +} + +.text-center { + text-align: center; +} + +.text-muted { + color: #999; +} + +.mono { + font-family: 'Courier New', monospace; + font-size: 11px; +} + +// ─── No Data ────────────────────────────────────────────────────────────────── +.no-data-block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + color: #aaa; + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 12px; + } + + p { + font-size: 14px; + margin: 0; + } +} + +// ─── Statement Footer ───────────────────────────────────────────────────────── +.statement-footer { + background: #f0f3f9; + border-top: 1px solid $border-color; + padding: 12px 24px; + text-align: center; + + .footer-note { + font-size: 10px; + color: #888; + margin-bottom: 4px; + font-style: italic; + } + + .footer-meta { + font-size: 10px; + color: #aaa; + letter-spacing: 0.3px; + } +} + +// ─── Responsive ─────────────────────────────────────────────────────────────── +@media (width <= 768px) { + .account-details-grid { + grid-template-columns: 1fr 1fr; + } + + .bank-header { + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + + .statement-label { + text-align: left; + } + + .balance-summary-strip { + flex-direction: column; + } + + .balance-divider { + width: auto; + height: 1px; + margin: 0 20px; + } + + .passbook-filter-form { + flex-direction: column; + align-items: stretch; + } + + .passbook-date-field { + width: 100%; + } + + .passbook-filter-spacer { + display: none; + } +} + +@media (width <= 480px) { + .account-details-grid { + grid-template-columns: 1fr; + } +} + +// ─── Print ──────────────────────────────────────────────────────────────────── +@media print { + .no-print { + display: none !important; + } + + .passbook-container { + background: #fff; + padding: 0; + } + + .statement-wrapper { + border: none; + box-shadow: none; + border-radius: 0; + } + + .bank-header { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .balance-summary-strip { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } +} diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts new file mode 100644 index 000000000..4b882635f --- /dev/null +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts @@ -0,0 +1,182 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, ElementRef, ViewChild, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { UntypedFormBuilder, Validators } from '@angular/forms'; +import { CurrencyPipe } from '@angular/common'; +import { MatIcon } from '@angular/material/icon'; +import { DateFormatPipe } from '../../../pipes/date-format.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Share Account Passbook — a printable, chronological (oldest-first) ledger of every + * Purchase/Redeem entry, mirroring PassbookTabComponent (Savings) but for share holdings: + * shares purchased/redeemed instead of amount deposited/withdrawn, plus the Board Resolution + * Number / Date of Meeting on redeem rows. + */ +@Component({ + selector: 'mifosx-shares-passbook-tab', + templateUrl: './passbook-tab.component.html', + styleUrls: ['./passbook-tab.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIcon, + DateFormatPipe, + CurrencyPipe + ] +}) +export class SharesPassbookTabComponent implements OnInit { + private route = inject(ActivatedRoute); + private formBuilder = inject(UntypedFormBuilder); + + @ViewChild('passbookContent') passbookContent: ElementRef; + + /** Account data */ + accountNo = ''; + clientName = ''; + productName = ''; + currency: any = null; + status = ''; + activatedOnDate: any = null; + resolutionNumber = ''; + savingsAccountNumber = ''; + totalApprovedShares = 0; + currentMarketPrice = 0; + + /** Transactions */ + allTransactions: any[] = []; + filteredTransactions: any[] = []; + + /** Date range form */ + minDate = new Date(2000, 0, 1); + maxDate = new Date(); + dateRangeForm: any; + isFiltered = false; + + get currentValue(): number { + return this.totalApprovedShares * this.currentMarketPrice; + } + + ngOnInit(): void { + this.route.parent.data.subscribe((data: { sharesAccountData: any }) => { + const account = data.sharesAccountData; + this.accountNo = account.accountNo || ''; + this.clientName = account.clientName || ''; + this.productName = account.productName || ''; + this.currency = account.currency || null; + this.status = account.status?.value || ''; + this.activatedOnDate = account.timeline?.activatedDate || null; + this.resolutionNumber = account.resolutionNumber || ''; + this.savingsAccountNumber = account.savingsAccountNumber || ''; + this.totalApprovedShares = account.summary?.totalApprovedShares || 0; + this.currentMarketPrice = account.currentMarketPrice || 0; + + const transactions = (account.purchasedShares || []).filter( + (transaction: any) => transaction.type.value !== 'Charge Payment' + ); + this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions)); + this.filteredTransactions = [...this.allTransactions]; + }); + + this.dateRangeForm = this.formBuilder.group({ + fromDate: [ + '', + Validators.required + ], + toDate: [ + '', + Validators.required + ] + }); + } + + isRedeem(transaction: any): boolean { + return transaction.type?.value === 'Redeem'; + } + + filterTransactions(): void { + const fromDate = this.dateRangeForm.value.fromDate; + const toDate = this.dateRangeForm.value.toDate; + + if (!fromDate || !toDate) return; + + const from = new Date(fromDate).getTime(); + const to = new Date(toDate).getTime(); + + this.filteredTransactions = this.allTransactions.filter((txn: any) => { + const txnDate = this.getTransactionDateAsTime(txn.purchasedDate); + return txnDate >= from && txnDate <= to; + }); + this.isFiltered = true; + } + + clearFilter(): void { + this.dateRangeForm.reset(); + this.filteredTransactions = [...this.allTransactions]; + this.isFiltered = false; + } + + private getTransactionDateAsTime(date: any): number { + if (Array.isArray(date) && date.length >= 3) { + return new Date(date[0], date[1] - 1, date[2]).getTime(); + } + return new Date(date).getTime(); + } + + printPassbook(): void { + const content = this.passbookContent?.nativeElement?.innerHTML; + if (!content) return; + + const printWindow = window.open('', 'SharePassbook', 'height=700,width=900'); + printWindow.document.write(` + + + Share Account Passbook - ${this.accountNo} + + + + ${content} + + + `); + printWindow.document.close(); + printWindow.focus(); + printWindow.print(); + printWindow.close(); + } + + private recalculateRunningBalances(transactions: any[]): any[] { + let balance = 0; + return transactions.map((txn) => { + const shares = Number(txn.numberOfShares) || 0; + balance += this.isRedeem(txn) ? -shares : shares; + return { ...txn, runningBalance: balance }; + }); + } + + private sortAscending(transactions: any[]): any[] { + return [...transactions].sort((a, b) => { + const dateA = this.getTransactionDateAsTime(a.purchasedDate); + const dateB = this.getTransactionDateAsTime(b.purchasedDate); + if (dateA !== dateB) return dateA - dateB; + return (a.id as number) - (b.id as number); + }); + } +} diff --git a/src/app/shares/shares-account-view/shares-account-view.component.html b/src/app/shares/shares-account-view/shares-account-view.component.html index 9761aeeef..7b1d31e33 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 @@ -124,6 +124,13 @@ + @if (sharesAccountData.status?.value === 'Active' && (sharesAccountData.summary?.totalApprovedShares ?? 0) === 0) { +
+ }