Files
cbsnexora/src/app/shares/shares-account-view/shares-account-view.component.ts
T
niteshandClaude Sonnet 5 44b23ef110
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
fix(shares): mandatory GL+voucher entry for advance collection, split entry-fee verification from settlement
- Apply Collection dialog: replace product-config auto-resolved GL account with a required GL +
  voucher search (mirrored for Share and Entry Fee receipts). The entry-fee receipt is tracked
  separately from the share receipts and sent as its own field so its amount is never swept into the
  Sundry-side total or double-posted — it only verifies the fee was paid, since that money already
  sits wherever the cashier deposited it.
- Shows the account's required total (share value + entry fee) and live coverage up front, instead of
  only after a failed apply.
- Apply Additional Shares: same mandatory GL + voucher pattern for its by-voucher lookup, plus a GL
  account picker fed by the new /shareadvancecollection/glaccounts endpoint.
- shares-account-charges-step: fixed a race where the currency-control subscription (meant only for
  the edit-an-existing-application flow) wiped out product charges that ngOnChanges had just seeded
  from the same product-selection event, leaving new accounts with no charges.
- shares-account-view: hide "Share Certificate" until collection_applied is true, so a certificate
  can't be printed for shares nobody has actually paid for yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 20:05:11 +05:30

300 lines
10 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 { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component';
import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component';
import { DebitFromSavingsDialogComponent } from './debit-from-savings-dialog/debit-from-savings-dialog.component';
/** Custom Services */
import { SharesService } from '../shares.service';
/** Custom Buttons Configuration */
import { SharesButtonsConfiguration } from './shares-buttons.config';
import {
MatCard,
MatCardHeader,
MatCardTitleGroup,
MatCardMdImage,
MatCardTitle,
MatCardContent
} from '@angular/material/card';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass } 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 { FormatNumberPipe } from '../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Shares Account View
*/
@Component({
selector: 'mifosx-shares-account-view',
templateUrl: './shares-account-view.component.html',
styleUrls: ['./shares-account-view.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCardHeader,
MatCardTitleGroup,
MatCardMdImage,
MatTooltip,
MatCardTitle,
NgClass,
LongTextComponent,
AccountNumberComponent,
MatIconButton,
MatMenuTrigger,
MatIcon,
FaIconComponent,
MatMenu,
MatMenuItem,
MatTabNav,
MatTabLink,
RouterLinkActive,
MatTabNavPanel,
RouterOutlet,
StatusLookupPipe,
FormatNumberPipe
]
})
export class SharesAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
dialog = inject(MatDialog);
/** Shares Account Data */
sharesAccountData: any;
/** Button Configurations */
buttonConfig: SharesButtonsConfiguration;
entityType: string;
/**
* Fetches shares account data from `resolve`
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SharesService} sharesService Shares Service
* @param {MatDialog} dialog Mat Dialog
*/
constructor() {
this.route.data.subscribe((data: { sharesAccountData: any }) => {
this.sharesAccountData = data.sharesAccountData;
});
}
/** Whether this account has a details edit awaiting checker approval. */
hasPendingEdit = false;
ngOnInit() {
this.setConditionalButtons();
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';
}
this.sharesService.getPendingShareAccountEdit(this.sharesAccountData.id).subscribe({
next: (edit: any) => {
this.hasPendingEdit = !!edit && edit.status === 'PENDING';
if (this.hasPendingEdit) {
this.buttonConfig.removeOption('Edit Details');
}
},
error: () => {
this.hasPendingEdit = false;
}
});
}
/**
* Adds options to button config. conditionaly.
*/
setConditionalButtons() {
const status = this.sharesAccountData.status.value;
this.buttonConfig = new SharesButtonsConfiguration(status);
if (this.sharesAccountData.charges) {
const charges: any[] = this.sharesAccountData.charges;
charges.forEach((charge: any) => {
if (charge.name === 'Annual fee - INR') {
this.buttonConfig.addOption({
name: 'Apply Anuual Fees',
taskPermissionName: 'APPLYANNUALFEE_SAVINGSACCOUNT'
});
}
});
}
if (status === 'Active') {
const purchasedShares: any[] = this.sharesAccountData.purchasedShares;
let sharesPendingForApproval = false;
purchasedShares.forEach((share: any) => {
if (
share.status.code === 'purchasedSharesStatusType.applied' &&
share.type.code === 'purchasedSharesType.purchased'
) {
sharesPendingForApproval = true;
}
});
if (!sharesPendingForApproval) {
this.buttonConfig.removeButton('Approve Additional Shares');
this.buttonConfig.removeButton('Reject Additional Shares');
}
// An Active account can carry "approved" shares nobody has actually paid for yet -- account
// creation, approval and activation post no GL of their own; only applying a Share Advance
// Collection does (see isSettled). A certificate for shares still unsettled would certify
// capital that was never issued.
if (!this.isSettled) {
this.buttonConfig.removeOption('Share Certificate');
}
}
}
/**
* Performs button action
* @param {string} name Action name
*/
doAction(name: string) {
switch (name) {
case 'Approve':
case 'Reject':
case 'Close':
case 'Activate':
case 'Undo Approval':
case 'Apply Additional Shares':
case 'Redeem Shares':
case 'Approve Additional Shares':
case 'Reject Additional Shares':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
break;
case 'Modify Application':
this.router.navigate(['edit'], { relativeTo: this.route });
break;
case 'Edit Details':
this.router.navigate(['edit-details'], { relativeTo: this.route });
break;
case 'Apply Collection':
this.applyCollection();
break;
case 'Debit from Savings':
this.debitFromSavings();
break;
case 'Share Certificate':
this.viewShareCertificate();
break;
case 'Delete':
this.deleteSharesAccount();
break;
}
}
/**
* Settles the member's Share Advance receipt against this account: the cash moves out of Share
* Sundry into share capital, and the purchase entry becomes visible in the transactions and
* passbook views.
*/
private applyCollection() {
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
width: '560px',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
accountNo: this.sharesAccountData.accountNo
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
// Account data comes from a route resolver, so re-running the route is what refreshes the
// newly revealed transaction and the now-paid charges.
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
this.router.navigate([
'/clients',
this.sharesAccountData.clientId,
'shares-accounts',
this.sharesAccountData.id
]);
});
}
});
}
/**
* Whether the member's money has actually been taken and the shares issued.
*
* Until then the account is only an application: nothing has been debited and the member holds
* nothing. The Transactions, Passbook and Charges tabs all describe money that has moved, so
* showing them at that point reads as though the member has paid and holds shares when they have
* done neither.
*
* Neither the account's status nor the purchase transaction's status can stand in for this: an
* account goes Approved then Activated before anything is settled, and the purchase reads
* Approved from the moment the account is approved. Only `collectionApplied` marks the point at
* which the money was actually taken — by cash collection or by debiting savings.
*/
get isSettled(): boolean {
return !!this.sharesAccountData?.collectionApplied;
}
/**
* Settles this account out of the member's linked savings instead of cash at the counter.
*
* Only records a request — a manager's approval is what debits the member and issues the shares —
* so unlike Apply Collection there is nothing to refresh here until that approval happens.
*/
private debitFromSavings() {
this.dialog.open(DebitFromSavingsDialogComponent, {
width: '560px',
data: {
shareAccountId: this.sharesAccountData.id,
clientId: this.sharesAccountData.clientId,
accountNo: this.sharesAccountData.accountNo
}
});
}
/**
* Deletes Shares Account.
*/
private deleteSharesAccount() {
const deleteSharesAccountDialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `shares account with id: ${this.sharesAccountData.id}` }
});
deleteSharesAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.sharesService.deleteSharesAccount(this.sharesAccountData.id).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
});
}
private viewShareCertificate() {
this.dialog.open(ShareCertificateDialogComponent, {
data: {
sharesAccountData: this.sharesAccountData,
clientId: this.sharesAccountData.clientId
},
width: '850px',
maxWidth: '95vw'
});
}
}