diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts index fca321b4b..0a297f206 100644 --- a/src/app/cashier/cashier-routing.module.ts +++ b/src/app/cashier/cashier-routing.module.ts @@ -78,6 +78,22 @@ const routes: Routes = [ ), 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/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/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.ts b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts index 836bfb920..30ea07971 100644 --- a/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts +++ b/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts @@ -361,11 +361,14 @@ export class ShareWithdrawalRequestComponent implements OnInit { if (!this.reviewRequest) return; this.wdService.approve(this.reviewRequest.id).subscribe({ next: (res: any) => { - const message = + 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.'); - this.snackBar.open(message, 'Close', { duration: 6000 }); + 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 { diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index d0fb025da..9a6154960 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 @@ -960,6 +960,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.linkIncomeToTurnover(); } + /** 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/core/authentication/authentication.service.ts b/src/app/core/authentication/authentication.service.ts index 0dea8be9a..5b37d82df 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/branch-scope.service.ts b/src/app/core/branch-scope.service.ts new file mode 100644 index 000000000..66da9d3d4 --- /dev/null +++ b/src/app/core/branch-scope.service.ts @@ -0,0 +1,98 @@ +/** + * 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 { AuthenticationService } from './authentication/authentication.service'; +import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data'; + +/** + * Resolves what branch data the logged-in user is allowed to see. + * + * Head Office sees every branch and can drill into any one of them; a branch login is + * locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing + * Summary and the NPA report with slightly different branch code lists; this service is + * the single place it lives so new reports cannot drift. + * + * The logged-in office is matched to a branch by name, since session credentials carry + * only officeId and officeName. Office names are seeded to match the GL branch names + * exactly (see Liquibase 0442). + */ +@Injectable({ providedIn: 'root' }) +export class BranchScopeService { + private auth = inject(AuthenticationService); + + /** Sentinel used when a branch login cannot be matched to a known branch. */ + static readonly UNRESOLVED = '__unresolved__'; + + get isHeadOffice(): boolean { + return this.auth.isHeadOfficeUser(); + } + + /** The office name on the session, for labelling the scope in report headers. */ + get officeName(): string { + return this.auth.getCredentials()?.officeName || 'Branch Office'; + } + + /** + * Branch code the user is confined to, or null for Head Office. + * + * Deliberately returns the UNRESOLVED sentinel rather than falling back to the first + * branch when the office cannot be matched: showing an unrecognised branch someone + * else's figures would be worse than showing them nothing. + */ + get ownBranchCode(): string | null { + if (this.isHeadOffice) { + return null; + } + const name = this.officeName.toLowerCase().trim(); + const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase()); + // Fall back to a substring match for offices named e.g. "Andheri (E) Branch". + const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase())); + return loose?.code ?? BranchScopeService.UNRESOLVED; + } + + /** True when the user may choose which branch to view. */ + get canSelectBranch(): boolean { + return this.isHeadOffice; + } + + /** Branches offered in a selector — everything for Head Office, nothing for a branch login. */ + get selectableBranches(): GlBranch[] { + return this.isHeadOffice ? GL_BRANCHES : []; + } + + /** Branch code a report should start on. '' means all branches consolidated. */ + get defaultBranchCode(): string { + return this.isHeadOffice ? '' : (this.ownBranchCode ?? ''); + } + + /** + * Forces a requested branch code into what the user is actually allowed to see. + * Call this wherever a branch code reaches a data query, so a stale or hand-edited + * selection cannot widen the user's scope. + */ + enforce(requested: string): string { + return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED); + } + + /** True when a branch login's office matched no known branch, so it sees no data. */ + get isUnresolved(): boolean { + return this.ownBranchCode === BranchScopeService.UNRESOLVED; + } + + /** Human label for the current scope, for report headers. */ + scopeLabel(selectedCode: string): string { + if (!this.isHeadOffice) { + return this.officeName; + } + if (!selectedCode) { + return 'All Branches (Consolidated)'; + } + return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode; + } +} diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 793ddf9cc..4d309aca4 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -112,6 +112,17 @@ My Share Requests + + + + + + My Share Closures + @if (clerkAccountLoaded) {
= { + '110101': 9_996_000, + '110102': 12_400_000, + '110103': 13_667_000, + // Surplus deposits parked with correspondent banks. Sized so the opening + // balance sheet tallies exactly — see the assertion in glPostings(). + '110104': 54_206_000, + '120101': 63_839_000, + '120102': 147_895_000, + '120201': 22_500_000, + '120202': 24_234_000, + '130101': 166_614_000, + '130102': 125_082_000, + '130103': 70_236_000, + '130104': 29_356_000, + '130105': 10_241_000, + '130106': 8_450_000, + '140101': 33_541_000, + '140102': 3_185_000, + '140103': 1_763_000, + '140104': 1_240_000, + '140105': 657_000, + '210101': 27_278_000, + '210201': 31_149_000, + '210202': 13_642_000, + '210203': 3_750_000, + '210204': 24_341_000, + '220101': 208_116_000, + '220102': 19_592_000, + '220103': 406_601_000, + '220104': 24_880_000, + '230101': 731_000, + '230102': 291_000, + '230103': 5_515_000, + '230104': 1_120_000, + '230105': 32_096_000, + '910101': 1_450_000, + '910102': 980_000, + '910103': 620_000, + '910104': 1_180_000, + '910105': 2_638_000, + '910106': 0, + '910107': 340_000 +}; + +/** Deterministic linear congruential generator — same figures on every load. */ +function lcg(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +function isoDate(d: Date): string { + const y = d.getUTCFullYear(); + const m = `${d.getUTCMonth() + 1}`.padStart(2, '0'); + const day = `${d.getUTCDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** Every posting date in the ledger period, Sundays excluded (bank holiday). */ +export function ledgerDates(): string[] { + const out: string[] = []; + const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`); + const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`); + while (cur <= end) { + if (cur.getUTCDay() !== 0) { + out.push(isoDate(cur)); + } + cur.setUTCDate(cur.getUTCDate() + 1); + } + return out; +} + +/** Templates for the vouchers the generator posts each day. */ +const VOUCHER_TEMPLATES: { + debit: string; + credit: string; + narration: string; + /** Rough rupee scale of the voucher. */ + scale: number; + /** Chance the voucher is raised on any given branch-day. */ + frequency: number; +}[] = [ + { debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 }, + { debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 }, + { debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 }, + { debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 }, + { debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 }, + { debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 }, + { debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 }, + { debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 }, + { debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 }, + { debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 }, + { debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 }, + { debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 }, + { debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 }, + { debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 }, + { debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 }, + // Interest on advances is the dominant earning line — sized so the bank runs a + // ~13% net margin rather than the loss the first-cut figures produced. + { debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 }, + { debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 }, + { debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 }, + { debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 }, + { debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 }, + { debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 }, + { debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 }, + { + debit: '410101', + credit: '230101', + narration: 'Interest provided on savings deposits', + scale: 128_000, + frequency: 0.8 + }, + { + debit: '410102', + credit: '230101', + narration: 'Interest provided on term deposits', + scale: 265_000, + frequency: 0.9 + }, + { debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 }, + { debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 }, + { debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 }, + { debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 }, + { debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 }, + { debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 }, + { debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 }, + { debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 }, + { debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 }, + { debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 }, + { debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 }, + { debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 }, + { + debit: '110102', + credit: '110101', + narration: 'Cash remitted to RBI currency chest', + scale: 1_800_000, + frequency: 0.2 + }, + { + debit: '110101', + credit: '110103', + narration: 'Funds drawn from correspondent bank', + scale: 1_400_000, + frequency: 0.2 + } +]; + +/** Head-Office-only vouchers — investments, reserves and centralised charges. */ +const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [ + { debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 }, + { + debit: '120102', + credit: '110102', + narration: 'Purchase of State Govt securities', + scale: 1_900_000, + frequency: 0.12 + }, + { debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 }, + { debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 }, + { debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 }, + { debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 }, + { debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 }, + { debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 }, + { debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 }, + { debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 } +]; + +/** + * Contingent-account movements are memoranda, each posted single-sided. + * Guarantees, LCs and collection bills are raised at the counter, so these run + * at every branch — not just Head Office. + */ +const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [ + { account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 }, + { account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 }, + { account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 }, + { account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 }, + { account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 }, + { account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 } +]; + +/** + * How the opening contingent book is spread across the network. Head Office + * carries the capital commitments and large claims; the branches carry the + * counter-issued guarantees. Weights are applied per account and the rounding + * remainder is absorbed by Head Office so each account's total stays exact. + */ +const CONTINGENT_BRANCH_WEIGHTS: Record = { + HO: 0.2, + '0002': 0.18, + '0003': 0.16, + '0004': 0.16, + '0005': 0.15, + '0006': 0.15 +}; + +let cachedPostings: GlPosting[] | null = null; + +/** + * The full generated ledger. Built once and memoised — every report reads from + * this array, so the reports can never disagree with one another. + */ +export function glPostings(): GlPosting[] { + if (cachedPostings) { + return cachedPostings; + } + const rand = lcg(20260401); + const out: GlPosting[] = []; + const dates = ledgerDates(); + let voucherSeq = 1; + + // Opening entries, posted at Head Office on day one so the books start in balance. + const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`; + let openingDebit = 0; + let openingCredit = 0; + + for (const [ + code, + amount + ] of Object.entries(OPENING_BALANCES)) { + if (!amount) { + continue; + } + const account = GL_ACCOUNTS.find((a) => a.code === code); + if (!account) { + continue; + } + const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT'; + + // Contingent accounts are single-sided memoranda held at the issuing branch, + // so their opening book is spread across the network rather than sat at HO. + if (account.type === 'CONTINGENT') { + let allocated = 0; + for (const branch of GL_BRANCHES) { + if (branch.code === 'HO') { + continue; + } + const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100; + if (!share) { + continue; + } + allocated += share; + out.push({ + date: GL_PERIOD.fyStart, + branchCode: branch.code, + accountCode: code, + debit: share, + credit: 0, + voucherNo: openingVoucher, + narration: 'Opening balance brought forward' + }); + } + // Head Office takes its own share plus whatever rounding left behind. + out.push({ + date: GL_PERIOD.fyStart, + branchCode: 'HO', + accountCode: code, + debit: amount - allocated, + credit: 0, + voucherNo: openingVoucher, + narration: 'Opening balance brought forward' + }); + continue; + } + + out.push({ + date: GL_PERIOD.fyStart, + branchCode: 'HO', + accountCode: code, + debit: debitSide ? amount : 0, + credit: debitSide ? 0 : amount, + voucherNo: openingVoucher, + narration: 'Opening balance brought forward' + }); + if (debitSide) { + openingDebit += amount; + } else { + openingCredit += amount; + } + } + + // If OPENING_BALANCES is ever edited so the two sides diverge, park the residual + // in a visibly-named suspense account rather than letting the trial balance drift. + const openingResidual = openingCredit - openingDebit; + if (openingResidual !== 0) { + out.push({ + date: GL_PERIOD.fyStart, + branchCode: 'HO', + accountCode: '140199', + debit: openingResidual > 0 ? openingResidual : 0, + credit: openingResidual < 0 ? -openingResidual : 0, + voucherNo: openingVoucher, + narration: 'Unreconciled opening balance difference' + }); + } + + const round = (n: number): number => Math.round(n / 100) * 100; + + for (const date of dates) { + for (const branch of GL_BRANCHES) { + // Head Office posts fewer counter transactions than the branches. + const weight = branch.code === 'HO' ? 0.45 : 1; + const templates = branch.code === 'HO' ? [ + ...VOUCHER_TEMPLATES, + ...HO_TEMPLATES + ] : VOUCHER_TEMPLATES; + + for (const tpl of templates) { + if (rand() > tpl.frequency * weight) { + continue; + } + // 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible. + const amount = round(tpl.scale * (0.55 + rand() * 0.9)); + if (amount <= 0) { + continue; + } + const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`; + out.push({ + date, + branchCode: branch.code, + accountCode: tpl.debit, + debit: amount, + credit: 0, + voucherNo, + narration: tpl.narration + }); + out.push({ + date, + branchCode: branch.code, + accountCode: tpl.credit, + debit: 0, + credit: amount, + voucherNo, + narration: tpl.narration + }); + } + + // Contingent memoranda are raised at every branch, weighted by its size. + const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15; + for (const tpl of CONTINGENT_TEMPLATES) { + // Claims against the bank and capital commitments are Head Office matters. + const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107'; + if (headOfficeOnly && branch.code !== 'HO') { + continue; + } + const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2); + if (rand() > frequency) { + continue; + } + const amount = round(tpl.scale * (0.55 + rand() * 0.9)); + out.push({ + date, + branchCode: branch.code, + accountCode: tpl.account, + debit: amount, + credit: 0, + voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`, + narration: tpl.narration + }); + } + } + } + + cachedPostings = out; + return out; +} + +/** + * Customer sub-ledger. + * + * The GL is account-level, so a client-wise report needs a customer dimension on top. + * Each customer holds a share of one GL head at one branch; balances are not stored, + * they are derived at query time as `share x that head's GL balance`. That way the + * client-wise report always reconciles to the General Ledger exactly, whatever period + * or branch is selected, instead of drifting as a second source of truth. + */ +export interface GlCustomer { + cif: string; + name: string; + branchCode: string; + /** GL head this relationship sits under. */ + accountCode: string; + accountNo: string; + /** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */ + share: number; +} + +const FIRST_NAMES = [ + 'Ravi', + 'Priya', + 'Vikram', + 'Nirav', + 'Kavita', + 'Arjun', + 'Lakshmi', + 'Sameer', + 'Anjali', + 'Rohan', + 'Meera', + 'Karan', + 'Divya', + 'Sanjay', + 'Pooja', + 'Amit', + 'Sneha', + 'Manish', + 'Rekha', + 'Deepak' +]; +const LAST_NAMES = [ + 'Sharma', + 'Patel', + 'Reddy', + 'Shah', + 'Verma', + 'Deshmukh', + 'Iyer', + 'Kapoor', + 'Nair', + 'Joshi', + 'Mehta', + 'Gupta', + 'Rao', + 'Bhatt', + 'Chauhan' +]; +const FIRMS = [ + 'Traders', + 'Enterprises', + 'Agencies', + '& Sons', + 'Industries', + 'Stores', + 'Corporation' +]; + +/** Customer-facing heads a client relationship can sit under. */ +const CUSTOMER_HEADS = [ + '220101', + '220102', + '220103', + '220104', + '130101', + '130102', + '130103', + '130104', + '130105', + '130106' +]; + +let cachedCustomers: GlCustomer[] | null = null; + +export function glCustomers(): GlCustomer[] { + if (cachedCustomers) { + return cachedCustomers; + } + const rand = lcg(76543210); + const out: GlCustomer[] = []; + let seq = 1; + + for (const branch of GL_BRANCHES) { + for (const head of CUSTOMER_HEADS) { + // Deposits are held by many customers; advances by fewer, larger ones. + const isAdvance = head.startsWith('13'); + const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4); + const weights: number[] = []; + for (let i = 0; i < count; i++) { + weights.push(0.35 + rand() * 0.65); + } + const weightTotal = weights.reduce((a, b) => a + b, 0); + + for (let i = 0; i < count; i++) { + const corporate = isAdvance && rand() > 0.55; + const name = corporate + ? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}` + : `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`; + out.push({ + cif: `CIF${`${100000 + seq}`}`, + name, + branchCode: branch.code, + accountCode: head, + accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`, + share: weights[i] / weightTotal + }); + seq++; + } + } + } + cachedCustomers = out; + return out; +} + +export function glAccount(code: string): GlAccount | undefined { + return GL_ACCOUNTS.find((a) => a.code === code); +} + +export function glBranchName(code: string): string { + return GL_BRANCHES.find((b) => b.code === code)?.name ?? code; +} diff --git a/src/app/shares/shares-account-view/shares-account-view.component.html b/src/app/shares/shares-account-view/shares-account-view.component.html index 4402b9160..006ee30a5 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) { + + }