Files
cbsnexora/src/app/savings/savings-account-view/savings-account-view.component.ts
T
niteshandClaude Opus 5 ee47a1ce68
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
feat(cashier,savings,tasks): clerk-manager-cashier account closure flow
Replaces the one-click Close action on the savings account page (removed from
the Danger Zone menu) with a maker-checker flow, since closing an account pays
real cash out of a teller's drawer.

Clerk: an Action button on Transaction Entry, shown once an ACTIVE savings
account is loaded. Ticking "Close this account" previews the payout -- balance
plus interest calculated to today by the same code the closure itself runs --
and Save drops that figure into the slip's AMOUNT. The clerk then fills
TOK. NO. on the slip and presses Save, which raises the request. Token and
narration deliberately live on the slip, not in the popup, so the closure is
entered like any other transaction.

Manager: a Closure Requests tab in the Approval Center, reviewed in a popup
dialog (matching Disbursement Requests) rather than a separate route.

Cashier: the closure token is recognised at the counter alongside withdrawal
and dividend tokens, auto-filling the rounded cash figure, locking it, and
closing the account once the denomination is counted.

Save-path fixes, all of which silently blocked a valid closure:
 - amount came from the denomination total whenever clerkMode was false --
   which it is for any route other than the Clerk Dashboard wrapper -- so the
   calculated payout was replaced by zero
 - the balance check rejected the payout because it legitimately exceeds
   today's withdrawable amount: it includes interest close() posts before
   withdrawing. Min-balance, withdrawal-facility and clerk-side denomination
   checks do not apply to a closure either
 - on the cashier side the account is already CLOSED, so accountDetails is a
   stub with no balance and the same check reported "Withdrawable 0.00"
 - the closure branch had to precede the plain clerk-withdrawal branch: a
   closure is a full-balance withdrawal, so that branch would have swallowed
   it and the account would never have closed
 - onSave's one silent return now says what is missing, instead of leaving
   Save looking like a broken button

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:45:26 +05:30

545 lines
19 KiB
TypeScript

/**
* 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, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
/** Custom Buttons Configuration */
import { SavingsButtonsConfiguration } from './savings-buttons.config';
import { SavingsService } from '../savings.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { AlertService } from 'app/core/alert/alert.service';
/** Environment Configuration */
import { environment } from '../../../environments/environment';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
import { LongTextComponent } from '../../shared/long-text/long-text.component';
import { AccountNumberComponent } from '../../shared/account-number/account-number.component';
import { MatIconButton } from '@angular/material/button';
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
/**
* Savings Account View Component
*
* Nexora redesign: gradient hero card with balance KPIs + pill-style tab
* navigation. No more `mat-card` chrome — pure custom layout.
*/
@Component({
selector: 'mifosx-savings-account-view',
templateUrl: './savings-account-view.component.html',
styleUrls: ['./savings-account-view.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
NgClass,
LongTextComponent,
AccountNumberComponent,
MatIconButton,
MatMenuTrigger,
MatIcon,
FaIconComponent,
MatMenu,
MatMenuItem,
MatTabNav,
MatTabLink,
RouterLinkActive,
MatTabNavPanel,
RouterOutlet,
CurrencyPipe,
StatusLookupPipe,
DormancyAlertPanelComponent
]
})
export class SavingsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private savingsService = inject(SavingsService);
private translateService = inject(TranslateService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
private alertService = inject(AlertService);
dialog = inject(MatDialog);
/** Savings Account Data */
savingsAccountData: any;
/** Savings Data Tables */
savingsDatatables: any;
/** Button Configurations */
buttonConfig: SavingsButtonsConfiguration;
/** Entity Type */
entityType: string;
isActive = false;
currency: Currency;
/**
* Categorisation map used by the action menu — every action name is
* bucketed into one of: 'transact' (cash movement), 'block' (restrict
* access), 'interest' (interest postings), 'admin' (approvals, charges,
* staff), 'danger' (close / reject / delete). Drives the section split
* and the colour of each icon.
*/
private readonly ACTION_KIND: Record<string, 'transact' | 'block' | 'interest' | 'admin' | 'danger'> = {
Deposit: 'transact',
Withdrawal: 'transact',
'Hold Amount': 'transact',
'Transfer Funds': 'transact',
'Interbank Transfer': 'transact',
'Block Deposit': 'block',
'Unblock Deposit': 'block',
'Block Withdrawal': 'block',
'Unblock Withdrawal': 'block',
'Block Account': 'block',
'Unblock Account': 'block',
'Calculate Interest': 'interest',
'Post Interest': 'interest',
'Post Interest As On': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
'Add Charge': 'admin',
'Apply Annual Fees': 'admin',
'Modify Application': 'admin',
'Edit Nominee & Features': 'admin',
'Assign Staff': 'admin',
'Unassign Staff': 'admin',
Approve: 'admin',
Activate: 'admin',
'Undo Approval': 'admin',
Close: 'danger',
Reject: 'danger',
Delete: 'danger',
'Withdrawn by Client': 'danger',
Terminate: 'danger'
};
/** Lightweight icon fallback for actions whose buttonConfig doesn't
* carry an icon (the `options` sub-menu items historically didn't). */
private readonly ACTION_ICON: Record<string, string> = {
Deposit: 'arrow-down',
Withdrawal: 'arrow-up',
'Hold Amount': 'pause',
'Transfer Funds': 'exchange-alt',
'Interbank Transfer': 'random',
'Block Deposit': 'lock',
'Unblock Deposit': 'lock-open',
'Block Withdrawal': 'lock',
'Unblock Withdrawal': 'lock-open',
'Block Account': 'lock',
'Unblock Account': 'lock-open',
'Calculate Interest': 'calculator',
'Post Interest': 'percentage',
'Post Interest As On': 'calendar-day',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
'Add Charge': 'plus-circle',
'Apply Annual Fees': 'receipt',
'Modify Application': 'edit',
'Edit Nominee & Features': 'edit',
'Assign Staff': 'user-plus',
'Unassign Staff': 'user-slash',
Approve: 'check-circle',
Activate: 'check',
'Undo Approval': 'undo',
Close: 'times-circle',
Reject: 'ban',
Delete: 'trash',
'Withdrawn by Client': 'user-slash',
Terminate: 'times-circle'
};
/** Returns the colour kind for a given action name (used to tint the
* icon and hover state in the dropdown). */
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
return this.ACTION_KIND[name] ?? 'admin';
}
iconOf(name: string, fallback?: string): string {
return this.ACTION_ICON[name] ?? fallback ?? 'circle';
}
hasKind(kind: 'transact' | 'block' | 'interest' | 'admin' | 'danger'): boolean {
const all = [
...(this.buttonConfig?.singleButtons ?? []),
...(this.buttonConfig?.options ?? [])
];
return all.some((item) => this.kindOf(item.name) === kind);
}
/**
* Fetches savings account data from `resolve`
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SavingsService} savingsService Savings Service
*/
constructor() {
this.route.data.subscribe((data: { savingsAccountData: any; savingsDatatables: any }) => {
this.savingsAccountData = data.savingsAccountData;
this.currency = this.savingsAccountData.currency;
this.savingsDatatables = data.savingsDatatables;
});
if (this.router.url.includes('clients')) {
this.entityType = 'Client';
} else if (this.router.url.includes('groups')) {
this.entityType = 'Group';
} else if (this.router.url.includes('centers')) {
this.entityType = 'Center';
}
}
ngOnInit() {
this.setConditionalButtons();
}
/**
* Adds options to button config. conditionaly.
*/
setConditionalButtons() {
const status = this.savingsAccountData.status.value;
this.isActive = status === 'Active';
const subStatus = this.savingsAccountData.subStatus;
this.buttonConfig = new SavingsButtonsConfiguration(status, subStatus);
// Transfer Funds is temporarily disabled for savings accounts (pending and active alike) --
// deliberately not added to the menu here; see the guarded 'Transfer Funds' switch case below.
if (this.savingsAccountData.externalId && environment.mifosInterbankTransfersEnabled) {
this.buttonConfig.addOption({
name: 'Interbank Transfer',
taskPermissionName: 'CREATE_ACCOUNTTRANSFER'
});
}
if (!this.savingsAccountData.fieldOfficerId) {
this.buttonConfig.addOption({
name: 'Assign Staff',
taskPermissionName: 'UPDATESAVINGSOFFICER_SAVINGSACCOUNT'
});
} else {
this.buttonConfig.addOption({
name: 'Unassign Staff',
taskPermissionName: 'REMOVESAVINGSOFFICER_SAVINGSACCOUNT'
});
}
if (this.savingsAccountData.charges) {
const charges: any[] = this.savingsAccountData.charges;
charges.forEach((charge: any) => {
if (charge.name === 'Annual fee - INR') {
this.buttonConfig.addOption({
name: 'Apply Annual Fees',
taskPermissionName: 'APPLYANNUALFEE_SAVINGSACCOUNT'
});
}
});
}
if (this.savingsAccountData.taxGroup) {
if (this.savingsAccountData.withHoldTax) {
this.buttonConfig.addOption({
name: 'Disable Withhold Tax',
taskPermissionName: 'UPDATEWITHHOLDTAX_SAVINGSACCOUNT'
});
} else {
this.buttonConfig.addOption({
name: 'Enable Withhold Tax',
taskPermissionName: 'UPDATEWITHHOLDTAX_SAVINGSACCOUNT'
});
}
}
if (!this.authenticationService.hasCashierRole()) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter(
(btn) => btn.name !== 'Deposit' && btn.name !== 'Withdrawal'
);
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter(
(opt) => opt.name !== 'Deposit' && opt.name !== 'Withdrawal'
);
}
// Closing an account is not a one-click action from here: it pays real cash out of a teller's
// drawer, so it goes through the Clerk -> Manager -> Cashier maker-checker flow instead. The
// clerk starts it with "Action > Close this account" on the account's own Transaction Entry
// screen (see TransactionEntryComponent#submitCloseAccount), which also shows the interest
// accrued to date before anything is submitted.
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter((btn) => btn.name !== 'Close');
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter((opt) => opt.name !== 'Close');
}
/**
* Refetches data foe the component
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
*/
private reload() {
const url: string = this.router.url;
const refreshUrl: string = this.router.url.slice(
0,
this.router.url.indexOf('savings-accounts') + 'savings-accounts'.length
);
this.router.navigateByUrl(refreshUrl, { skipLocationChange: true }).then(() => this.router.navigate([url]));
}
/**
* Performs action button/option action.
* @param {string} name action name.
*/
doAction(name: string) {
switch (name) {
case 'Approve':
case 'Reject':
case 'Deposit':
case 'Activate':
case 'Close':
case 'Undo Approval':
case 'Post Interest As On':
case 'Assign Staff':
case 'Add Charge':
case 'Hold Amount':
case 'Block Account':
case 'Block Deposit':
case 'Block Withdrawal':
case 'Unassign Staff':
case 'Withdrawn by Client':
case 'Apply Annual Fees':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
break;
case 'Withdrawal':
this.router.navigate([`actions/Withdrawal`], { relativeTo: this.route });
break;
case 'Modify Application':
this.router.navigate(['edit'], { relativeTo: this.route });
break;
case 'Edit Nominee & Features':
this.router.navigate(['edit-nominee-features'], { relativeTo: this.route });
break;
case 'Delete':
this.deleteSavingsAccount();
break;
case 'Terminate':
this.terminateSavingsAccount();
break;
case 'Calculate Interest':
this.calculateInterest();
break;
case 'Post Interest':
this.postInterest();
break;
case 'Enable Withhold Tax':
this.enableWithHoldTax();
break;
case 'Disable Withhold Tax':
this.disableWithHoldTax();
break;
case 'Transfer Funds':
// Temporarily disabled for savings accounts -- blocked here too, not just left out of the
// menu, so a stale menu render or direct invocation can't reach the transfer screen.
this.alertService.alert({
type: 'Transfer Funds',
message: 'Transfer Funds is temporarily unavailable for savings accounts.'
});
break;
case 'Interbank Transfer': {
const queryParams: any = { interbank: true, savingsId: this.savingsAccountData.id, accountType: 'interbank' };
this.router.navigate(['transfer-funds/make-account-transfer'], {
relativeTo: this.route,
queryParams: queryParams,
state: { balance: this.savingsAccountData.summary.availableBalance }
});
break;
}
case 'Unblock Account':
case 'Unblock Deposit':
case 'Unblock Withdrawal':
this.unblockSavingsAccount(name);
break;
}
}
/**
* Deletes Savings Account.
*/
private deleteSavingsAccount() {
const deleteSavingsAccountDialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `savings account with id: ${this.savingsAccountData.id}` }
});
deleteSavingsAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.savingsService.deleteSavingsAccount(this.savingsAccountData.id).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
});
}
/**
* Terminates Savings Account.
*/
private terminateSavingsAccount() {
const terminateSavingsAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateSavingsAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'SAVINGS',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.savingsAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/**
* Calculates savings account's interest
*/
private calculateInterest() {
const calculateInterestAccountDialogRef = this.dialog.open(CalculateInterestDialogComponent);
calculateInterestAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response.confirm) {
this.savingsService
.executeSavingsAccountCommand(this.savingsAccountData.id, 'calculateInterest', {})
.subscribe(() => {
this.reload();
});
}
});
}
/**
* Posts savings account's interest
*/
private postInterest() {
const postInterestAccountDialogRef = this.dialog.open(PostInterestDialogComponent);
postInterestAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response.confirm) {
this.savingsService
.executeSavingsAccountCommand(this.savingsAccountData.id, 'postInterest', {})
.subscribe(() => {
this.reload();
});
}
});
}
/**
* Enables withhold tax for savings account.
*/
private enableWithHoldTax() {
const deleteSavingsAccountDialogRef = this.dialog.open(ToggleWithholdTaxDialogComponent, {
data: { isEnable: true }
});
deleteSavingsAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response.confirm) {
this.savingsService
.executeSavingsAccountUpdateCommand(this.savingsAccountData.id, 'updateWithHoldTax', { withHoldTax: true })
.subscribe(() => {
this.reload();
});
}
});
}
/**
* Disables withhold tac for savings account
*/
private disableWithHoldTax() {
const disableWithHoldTaxDialogRef = this.dialog.open(ToggleWithholdTaxDialogComponent, {
data: { isEnable: false }
});
disableWithHoldTaxDialogRef.afterClosed().subscribe((response: any) => {
if (response.confirm) {
this.savingsService
.executeSavingsAccountUpdateCommand(this.savingsAccountData.id, 'updateWithHoldTax', { withHoldTax: false })
.subscribe(() => {
this.reload();
});
}
});
}
/**
* Unblock Savings Account.
*/
private unblockSavingsAccount(action: string) {
const unblockSavingsAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: this.translateService.instant('labels.heading.Savings Account'),
dialogContext:
this.translateService.instant('labels.dialogContext.Are you sure you want') +
action +
this.translateService.instant('this Savings Account')
}
});
let command = 'unblock';
if (action === 'Unblock Deposit') {
command = 'unblockCredit';
}
if (action === 'Unblock Withdrawal') {
command = 'unblockDebit';
}
unblockSavingsAccountDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
if (response.confirm) {
this.savingsService.executeSavingsAccountCommand(this.savingsAccountData.id, command, {}).subscribe(() => {
this.showUnblockSuccess(action);
});
}
});
}
/**
* Pops the shared success dialog with copy tuned to the unblock variant
* the cashier just performed, then reloads the savings view so the
* account substatus shows as unblocked.
*/
private showUnblockSuccess(action: string): void {
let title = 'Account Unblocked Successfully';
let message = 'Account activity has been restored.';
if (action === 'Unblock Deposit') {
title = 'Deposits Unblocked Successfully';
message = 'Incoming credits to this savings account are accepted again.';
} else if (action === 'Unblock Withdrawal') {
title = 'Withdrawals Unblocked Successfully';
message = 'Outgoing debits from this savings account are accepted again.';
}
const ref = this.dialog.open(SuccessDialogComponent, {
width: '420px',
maxWidth: '92vw',
autoFocus: false,
data: { title, message, buttonLabel: 'Done' }
});
ref.afterClosed().subscribe(() => this.reload());
}
}