Files
cbsnexora/src/app/deposits/party-master/create-party/create-party.component.ts
T

1945 lines
70 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 {
UntypedFormBuilder,
UntypedFormGroup,
UntypedFormArray,
Validators,
ValidatorFn,
ValidationErrors,
AbstractControl
} from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { MatTabsModule } from '@angular/material/tabs';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatDialog } from '@angular/material/dialog';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { searchClientsAnyCase } from '../client-search';
/** Custom Components */
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
/**
* Party Master Entry form.
*
* The customer's DDS account - opened under a scheme (Sub Type, chosen from the DDS Account Type
* master) and assigned to the agent who will collect for it daily. Carries its own KYC, scheme
* terms, two-stage overdue flags and a nominee list. Modelled on the legacy Party Master Entry
* screen, field for field.
*
* The legacy screen filled the identity block from the Client ID and showed the agent's name beside
* the agent field; both are reproduced here, and the same lookup fills the guardian's name for a
* minor's account. That field read "Sub Agent" in the legacy system, which had a tier this one does
* not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or
* written by collections and the batch job - none of them are typed here.
*
* "Close Account" (command=close) permanently closes the Party once its balance is zero - the
* backend rejects the command otherwise. A closed Party can no longer be edited or receive new
* Party Tran collections.
*
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
* the same client (resolved by account number, since that's what an officer has to hand) -
* clarified with the business (2026-08-24) as "link it if the client already has one, otherwise
* leave it". There is no unlink once linked.
*/
@Component({
selector: 'mifosx-create-party',
templateUrl: './create-party.component.html',
styleUrls: ['./create-party.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatCheckbox,
MatRadioModule,
MatAutocompleteModule,
// Holds the identifier fields to the shape their kind takes, at the keystroke. Imported here
// because a standalone component silently ignores a directive it has not imported - the
// attribute stays on the element and simply does nothing.
RestrictInputDirective
]
})
export class CreatePartyComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private dialog = inject(MatDialog);
/*
* What each identifier has to look like - the same shapes the server holds them to.
*
* Checked here as well so the clerk is told while the customer is still at the counter, rather
* than after a save that comes back rejected. The server checks them again regardless: a screen
* is a convenience, not a guarantee.
*/
private static readonly MOBILE_RE = /^[6-9][0-9]{9}$/;
private static readonly TELEPHONE_RE = /^[0-9+()\-\s]{6,20}$/;
private static readonly PINCODE_RE = /^[1-9][0-9]{5}$/;
/** Twelve digits, or the masked form CKYC hands back. */
private static readonly AADHAAR_RE = /^([0-9]{12}|[Xx]{8}[0-9]{4})$/;
private static readonly PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
private static readonly CKYC_RE = /^[0-9]{14}$/;
partyId: number | null = null;
/**
* The earliest opening date the picker will offer on a new account - today, so the past is greyed
* out. The server refuses a past date on creation too; this only stops the clerk reaching for one.
*
* Null while editing. An account already on the books opened in the past by definition, so a floor
* of today would put every existing party into a state its own form would not accept.
*/
get minOpeningDate(): Date | null {
return this.partyId === null ? new Date() : null;
}
selectedTabIndex = 0;
partyStatus = 'ACTIVE';
linkedSavingsAccountId: number | null = null;
/**
* The customer's real deposit account, as the bank's own books hold it. Read from the account
* itself rather than copied onto the party, so what shows here cannot drift from what is true.
*/
savingsAccountNo: string | null = null;
savingsProductName: string | null = null;
savingsAccountStatus: string | null = null;
savingsAccountBalance: number | null = null;
/** The DDS account's own number, scheme and balance - the party is the account. */
accountNo: string | null = null;
accountSchemeName: string | null = null;
accountBalance: number | null = null;
/**
* A minor account holder who has come of age. The guardian's authority ended on that day, so the
* branch has to take fresh KYC and a new mandate - shown as a call to action rather than a field.
*/
majorOn: string | null = null;
minorTurnedMajor = false;
/** Shown beside the id fields once the record behind them has been resolved. */
loadedClientName = '';
clientLookupFailed = false;
loadedAgentName = '';
agentLookupFailed = false;
loadedGuardianName = '';
/** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */
clientOptions: any[] = [];
/**
* The first page of clients, shown the moment the field is focused so the officer can pick from a
* list instead of having to know something to type. Fetched once and kept.
*
* Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank
* with far more later, and the field has to stay usable in both. Typing still searches every
* client, not just these - see searchClients.
*/
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
/** How many clients exist in total, to say so when the list below is only part of them. */
clientTotal = 0;
/** True while the options being shown are the opening list rather than search results. */
showingClientList = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
guardianOptions: any[] = [];
/** The account types matching what has been typed into Sub Type. */
subTypeOptions: any[] = [];
/**
* The agents on offer. Every agent is fetched once and filtered here rather than asked of the
* server per keystroke - there is a handful of them, not a branch's worth of customers.
*/
agentOptions: any[] = [];
private allAgents: any[] = [];
get isClosed(): boolean {
return this.partyStatus === 'CLOSED';
}
/** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */
subTypes: any[] = [];
operations = [
'Self',
'Guardian',
'Joint'
];
occupations = [
'Business',
'Service',
'Agriculture',
'Homemaker',
'Student',
'Retired',
'Other'
];
riskLevels = [
'Low',
'Medium',
'High'
];
adultMinorOptions = [
'Adult',
'Minor'
];
withdrawTypes = [
'Cash',
'Transfer',
'Clearing'
];
freezeCodes = [
'Normal',
'Debit Freeze',
'Credit Freeze',
'Total Freeze'
];
advCodes = [
'None',
'Transfer To Saving',
'Renew'
];
/** The bank's own branches - where maturity proceeds can be credited. */
/**
* Why a deposit gets held, offered as a list rather than typed.
*
* Free text meant the same reason arrived four ways - "Loan 1234", "loan no 1234", "LOAN#1234" -
* and anything counting them read four different reasons. Picked from a list they group properly,
* and the clerk types only where the list genuinely does not cover it.
*/
lienReasons = [
'Loan security',
'Court order',
'Tax attachment',
'Deceased — claim pending',
'Under investigation',
'Other'
];
offices: any[] = [];
officesError = '';
/**
* The customer's own savings accounts, for Credit To A/C.
*
* Picked rather than typed. The whole matured amount lands here years after somebody fills this
* in, and the live data showed what free text costs: one party carried "1234567890111", which is
* no account at all. Only savings accounts are offered - a fixed or recurring deposit cannot take
* the credit - and only this customer's own, which is what a maturity instruction means in any
* core system. The backend refuses anything else, whatever the screen sends.
*/
creditToAccounts: any[] = [];
/** The Adv Code that makes the account say where its maturity proceeds should land. */
private static readonly TRANSFER_TO_SAVING = 'Transfer To Saving';
/** Filled from the Client ID, as on the legacy screen. */
private static readonly CLIENT_DERIVED_FIELDS = [
'partyName',
'partyName1',
'address1',
'address2',
'address3',
'city',
'pincode',
'gender',
'birthDate',
'telephone',
'mobileNoSms',
'occupation',
'risk',
'aadharNo',
'panCardNo',
'ckycId',
'relation',
'minorDate',
'guardianClientId'
];
/** Fields holding a value from the client record, shown read-only - see fillFromClientRecord. */
private filledFromClient = new Set<string>();
private static readonly MINOR_FIELDS = [
'minorDate',
'guardianClientId',
'minAccountOpt',
'relation'
];
private static readonly CREDIT_TO_FIELDS = [
'creditToBranch',
'creditToAccountType',
'creditToAccount'
];
partyForm: UntypedFormGroup = this.formBuilder.group({
clientId: [
'',
Validators.required
],
subType: [
'',
Validators.required
],
agentId: [
'',
Validators.required
],
// The branch this account belongs to. Its own, not the collecting agent's - an agent of one
// branch may hold accounts for customers of another, so it is asked for rather than inherited.
officeId: [
'',
Validators.required
],
savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [
{
value: '',
disabled: true
}
],
nominal: [
'No',
Validators.required
],
aadharNo: [
'',
Validators.pattern(CreatePartyComponent.AADHAAR_RE)
],
partyName: [
'',
Validators.required
],
partyName1: [''],
address1: [
'',
Validators.required
],
address2: [''],
address3: [''],
city: [
'',
Validators.required
],
pincode: [
'',
[
Validators.required,
Validators.pattern(CreatePartyComponent.PINCODE_RE)
]
],
gender: [''],
panCardNo: [
'',
Validators.pattern(CreatePartyComponent.PAN_RE)
],
telephone: [
'',
Validators.pattern(CreatePartyComponent.TELEPHONE_RE)
],
mobileNoSms: [
'',
[
Validators.required,
Validators.pattern(CreatePartyComponent.MOBILE_RE)
]
],
operation: [
'Self',
Validators.required
],
birthDate: [''],
occupation: [''],
risk: [''],
ckycId: [
'',
Validators.pattern(CreatePartyComponent.CKYC_RE)
],
adultMinor: [
'Adult',
Validators.required
],
// Enabled only for a minor's account - see applyAdultMinorRules().
minorDate: [
{
value: '',
disabled: true
}
],
guardianClientId: [
{
value: '',
disabled: true
}
],
minAccountOpt: [
{
value: '',
disabled: true
}
],
relation: [
{
value: '',
disabled: true
}
],
openingDate: [
'',
Validators.required
],
month: [''],
// Worked out from the opening date and the tenure - never typed.
maturityDate: [
{
value: '',
disabled: true
}
],
reopenDate: [''],
depositAmount: [0],
dailyDepositAmount: [
0,
Validators.required
],
days: [
'',
Validators.required
],
ledgerNo: [''],
maturityAmount: [
{
value: 0,
disabled: true
}
],
interestRate: [0],
withdrawType: [''],
// The overdue flags, balances and interest figures below are written by collections and the
// DDS batch job. They are shown so an officer can see where the account stands, never typed.
od1Flag: [
{
value: false,
disabled: true
}
],
od2Flag: [
{
value: false,
disabled: true
}
],
lien: ['No'],
// Asked for only while the lien is set - the rule is applied from the flag rather
// than stated here, so switching it off does not leave a required field behind.
lienReason: [''],
// What the clerk types when the list does not cover it. Kept apart from lienReason so the list
// stays a list - the one value the server stores is worked out at save.
lienReasonOther: [''],
payDate: [
{
value: '',
disabled: true
}
],
paymentAmount: [
{
value: 0,
disabled: true
}
],
currentBalance: [
{
value: 0,
disabled: true
}
],
openingBalance: [
{
value: 0,
disabled: true
}
],
isSmsActive: [true],
isEmailActive: [false],
interestCalc: [
{
value: 0,
disabled: true
}
],
interestDate: [
{
value: '',
disabled: true
}
],
spDescription: [''],
freezeCode: ['Normal'],
advCode: ['None'],
// Mandatory only when Adv Code is Transfer To Saving - see applyAdvCodeRules().
creditToBranch: [''],
creditToAccountType: [''],
creditToAccount: [''],
nomineeApplicable: ['No'],
nomineeSplitMode: ['Percentage'],
nominees: this.formBuilder.array([])
});
ngOnInit(): void {
this.productsService.getDdsAccountTypes().subscribe((accountTypes: any[]) => {
// A retired scheme takes no new accounts, but one already on it must still show when edited.
this.subTypes = (accountTypes || []).filter((accountType) => accountType.active);
this.subTypeOptions = this.subTypes;
});
// Branches are picked, never typed: a free-text branch cannot be matched back to a real office
// when the proceeds are actually moved. If the list cannot be fetched the reason is shown on the
// field - an empty dropdown with no explanation looks like the bank has no branches.
this.organizationService.getOffices().subscribe({
next: (offices: any) => {
this.offices = Array.isArray(offices) ? offices : offices?.pageItems || [];
this.officesError = this.offices.length === 0 ? 'No branches on file' : '';
},
error: (error: any) => {
this.officesError =
error?.error?.defaultUserMessage ||
error?.error?.developerMessage ||
`Could not load branches (${error?.status || 'no response'})`;
}
});
const routeId = this.route.snapshot.paramMap.get('partyId');
if (routeId) {
this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party));
}
this.partyForm.get('days')?.addValidators(this.withinTerm());
this.wireClientLookup();
this.wireSubTypeSearch();
this.wireAgentLookup();
this.wireGuardianLookup();
this.wireConditionalRules();
this.wireMaturityCalculation();
}
// ---------------------------------------------------------------- lookups
/**
* Fills the identity block from the customer, the way the legacy screen did - except that the
* customer can be found by name as well as by id, since an officer at the counter is told a name.
* The control holds the picked client object, or whatever has been typed so far.
*/
private wireClientLookup(): void {
this.partyForm
.get('clientId')
?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((value) => {
// Always replace: changing the customer must not leave the previous one's details behind.
for (const controlName of CreatePartyComponent.CLIENT_DERIVED_FIELDS) {
this.partyForm.get(controlName)?.setValue('');
}
this.filledFromClient.clear();
this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
this.loadedClientName = '';
this.clientLookupFailed = false;
// Picked from the list.
if (value && typeof value === 'object') {
this.clientOptions = [];
this.fillFromClient(value.id, value.displayName);
return;
}
const typed = String(value ?? '').trim();
if (!typed) {
// Emptying the field puts the opening list back, rather than leaving nothing to pick from.
this.showClientList();
return;
}
// A bare number is an id typed straight in; anything else is a name to search for. A number
// can be both, so both are tried and whichever matches wins.
if (/^\d+$/.test(typed)) {
this.fillFromClient(typed, '');
}
this.searchClients(typed);
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = found.slice(0, 25);
});
}
/**
* Opens the customer list when the field is focused.
*
* Without this the officer has to know something before anything appears, which is backwards: the
* point of the field is to find a customer, and a blank box is no help in doing that. A customer
* already picked is left alone - reopening the list over a chosen name only invites a misclick.
*/
onClientFocus(): void {
if (this.partyForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the opening list, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.initialClients;
return;
}
this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.initialClients;
},
// A failed list is not a failed screen - the officer can still type a name or an id.
error: () => (this.clientOptions = [])
});
}
private fillFromClient(clientId: string | number, knownName: string): void {
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
this.loadedClientName = client?.displayName || knownName || '';
this.clientLookupFailed = false;
this.fillFromClientRecord('partyName', client?.displayName);
this.fillFromClientRecord('mobileNoSms', client?.mobileNo);
this.fillFromClientRecord('telephone', client?.alternateMobileNo);
// Alerts go where the client can actually be reached: SMS with a mobile on record, email with
// an email address on record.
this.partyForm.get('isSmsActive')?.setValue(!!client?.mobileNo);
this.partyForm.get('isEmailActive')?.setValue(!!client?.emailAddress);
// The client record says FEMALE; this form's list says Female.
this.fillFromClientRecord('gender', titleCase(client?.gender?.name || client?.gender));
const dateOfBirth = this.toDate(client?.dateOfBirth);
if (dateOfBirth) {
this.partyForm.get('birthDate')?.setValue(dateOfBirth);
this.filledFromClient.add('birthDate');
}
// The client's own occupation, offered even when this form's short list does not carry it.
const occupation = client?.occupation?.name || client?.occupationCategory?.name;
if (occupation) {
if (!this.occupations.includes(occupation)) {
this.occupations = [
...this.occupations,
occupation
];
}
this.fillFromClientRecord('occupation', occupation);
}
// A minor on the client record is a minor account here, with the same guardian.
this.partyForm.get('adultMinor')?.setValue(client?.isMinor ? 'Minor' : 'Adult');
// Adult or minor is the client record's, checked when the client was opened - not changed here.
this.partyForm.get('adultMinor')?.disable({ emitEvent: false });
this.filledFromClient.add('adultMinor');
if (client?.isMinor) {
if (dateOfBirth) {
// The day they come of age - eighteen is majority in India.
const majority = new Date(dateOfBirth);
majority.setFullYear(majority.getFullYear() + 18);
this.partyForm.get('minorDate')?.setValue(majority);
this.filledFromClient.add('minorDate');
}
if (client?.guardianClientId) {
this.partyForm
.get('guardianClientId')
?.setValue({ id: client.guardianClientId, displayName: client.guardianClientDisplayName || '' });
this.filledFromClient.add('guardianClientId');
}
this.fillFromClientRecord('relation', titleCase(client?.guardianRelation));
}
this.loadClientRisk(String(clientId));
// Nominees are not brought across: the officer records them for this account.
// Proceeds usually go back to an account at the customer's own branch, so that is offered
// first - any branch can still be picked, since the account may well be at another.
const creditToBranch = this.partyForm.get('creditToBranch');
if (client?.officeName && !creditToBranch?.value) {
creditToBranch?.setValue(client.officeName, { emitEvent: false });
}
this.loadClientAddress(String(clientId));
this.loadClientIdentifiers(String(clientId));
this.loadCreditToAccounts(String(clientId));
},
error: () => {
this.clientLookupFailed = true;
}
});
}
/**
* The customer's own accounts a maturity may be credited to.
*
* Worked out by the server, which applies the same conditions the payout enforces years later: an
* active savings account of theirs, not a fixed or recurring deposit, and not a DDS scheme account
* - those are collected on daily and pay out at their own maturity, so naming one here would send
* this maturity into another collection account instead of back to the customer.
*
* Deciding it here instead let the list and the refusal drift apart, and the accounts on offer
* came to include a scheme account of a party that had already closed.
*/
private loadCreditToAccounts(clientId: string): void {
this.organizationService.getCreditableAccounts(clientId).subscribe({
next: (accounts: any) => (this.creditToAccounts = accounts || []),
error: () => (this.creditToAccounts = [])
});
}
/**
* Fills the account type from the account chosen. An account already is of a type, and asking for
* both lets the two disagree - the account is the half that is actually true.
*/
onCreditToAccountPicked(accountNo: string): void {
const account = this.creditToAccounts.find((each: any) => each.accountNo === accountNo);
if (account?.productName) {
this.partyForm.get('creditToAccountType')?.setValue(account.productName);
}
}
/**
* Aadhar, PAN and CKYC are not plain client fields - they are KYC identifiers filed against the
* client, each tagged with a document type. They are matched by that type's name so the screen
* shows the same numbers the client record already holds, rather than asking for them twice.
*/
private loadClientIdentifiers(clientId: string): void {
this.clientsService.getClientIdentifiers(clientId).subscribe({
next: (identifiers: any) => {
for (const identifier of Array.isArray(identifiers) ? identifiers : []) {
const words = this.wordsOf(identifier?.documentType?.name);
const documentKey = identifier?.documentKey;
if (!documentKey) {
continue;
}
if (words.includes('aadhaar') || words.includes('aadhar')) {
this.fillFromClientRecord('aadharNo', documentKey);
} else if (words.includes('pan')) {
this.fillFromClientRecord('panCardNo', documentKey);
} else if (words.includes('ckyc')) {
this.fillFromClientRecord('ckycId', documentKey);
}
}
},
// A client with no identifiers on file is normal - the officer just types them.
error: () => undefined
});
}
/** What the Client ID box shows for a picked client, rather than "[object Object]". */
displayClient = (value: any): string => {
if (!value) {
return '';
}
return typeof value === 'object' ? `${value.id} — ${value.displayName}` : String(value);
};
/**
* Takes a value from the client record, which was checked when the client was opened, and locks
* the field so it is not typed - and checked - a second time. A value the client does not have is
* left for the officer to enter.
*/
private fillFromClientRecord(controlName: string, value: any): void {
if (value === null || value === undefined || String(value).trim() === '') {
return;
}
this.setField(controlName, value);
this.filledFromClient.add(controlName);
}
/** Whether a field holds a value taken from the client record, and so is shown read-only. */
isFromClient(controlName: string): boolean {
return this.filledFromClient.has(controlName);
}
/** The client's risk category, from their KYC record. */
private loadClientRisk(clientId: string): void {
this.clientsService.getClientKyc(clientId, true).subscribe({
next: (kyc: any) => {
// kycRiskCategory.medium -> Medium
const code = String(kyc?.riskCategoryCode || '')
.split('.')
.pop();
this.fillFromClientRecord('risk', titleCase(code));
},
// A client without a KYC record just leaves the risk to be chosen.
error: () => undefined
});
}
private loadClientAddress(clientId: string): void {
this.clientsService.getClientAddressData(clientId, true).subscribe({
next: (addresses: any) => {
const address = Array.isArray(addresses) ? addresses[0] : addresses;
if (!address) {
return;
}
this.fillFromClientRecord('address1', address.addressLine1 || address.street);
this.fillFromClientRecord('address2', address.addressLine2);
this.fillFromClientRecord('address3', address.addressLine3);
this.fillFromClientRecord('city', address.city);
this.fillFromClientRecord('pincode', address.postalCode);
},
// A client with no address on file is normal - the officer just types it.
error: () => undefined
});
}
/** Shows the collecting agent's name beside the id, as the legacy screen did in red. */
private wireAgentLookup(): void {
this.partyForm
.get('agentId')
?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((value) => {
this.loadedAgentName = '';
this.agentLookupFailed = false;
// Picked from the list - the agent is already in hand, nothing to look up.
if (value && typeof value === 'object') {
this.agentOptions = [];
this.loadedAgentName = value.agentNameId || value.clientName || '';
this.agentLookupFailed = !value.active;
return;
}
const typed = String(value ?? '').trim();
if (!typed) {
this.showAgentList();
return;
}
this.filterAgents(typed);
// A bare number is an id typed straight in, the way this field always worked.
if (!/^\d+$/.test(typed)) {
return;
}
this.organizationService.getAgent(typed).subscribe({
next: (agent: any) => {
this.loadedAgentName = agent?.agentNameId || agent?.clientName || '';
this.agentLookupFailed = !agent?.active;
},
error: () => {
this.agentLookupFailed = true;
}
});
});
}
/**
* Opens the agent list on focus, so the field can be answered by picking rather than by knowing
* an id. A chosen agent is left alone - reopening over it only invites a misclick.
*/
onAgentFocus(): void {
if (this.partyForm.get('agentId')?.value) {
return;
}
this.showAgentList();
}
private showAgentList(): void {
if (this.allAgents.length > 0) {
this.agentOptions = this.allAgents;
return;
}
this.organizationService.getAgents().subscribe({
next: (agents: any[]) => {
this.allAgents = agents || [];
this.agentOptions = this.allAgents;
},
// A failed list is not a failed screen - an id can still be typed.
error: () => (this.agentOptions = [])
});
}
/** Matches on code or name, since an officer may know either. */
private filterAgents(term: string): void {
const needle = term.toLowerCase();
const match = (agent: any) =>
String(agent.agentCode || '')
.toLowerCase()
.includes(needle) ||
String(agent.agentNameId || '')
.toLowerCase()
.includes(needle) ||
String(agent.id) === term;
if (this.allAgents.length > 0) {
this.agentOptions = this.allAgents.filter(match);
return;
}
this.organizationService.getAgents().subscribe({
next: (agents: any[]) => {
this.allAgents = agents || [];
this.agentOptions = this.allAgents.filter(match);
},
error: () => (this.agentOptions = [])
});
}
displayAgent = (value: any): string => {
if (!value) {
return '';
}
return typeof value === 'object' ? `${value.agentCode} — ${value.agentNameId}` : String(value);
};
/** A minor's account names a guardian, found the same way the customer is. */
private wireGuardianLookup(): void {
this.partyForm
.get('guardianClientId')
?.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((value) => {
this.loadedGuardianName = '';
if (value && typeof value === 'object') {
this.guardianOptions = [];
this.loadedGuardianName = value.displayName || '';
return;
}
const typed = String(value ?? '').trim();
if (!typed) {
this.guardianOptions = [];
return;
}
if (/^\d+$/.test(typed)) {
this.clientsService.getClientData(typed, true).subscribe({
next: (client: any) => (this.loadedGuardianName = client?.displayName || ''),
error: () => undefined
});
}
this.clientsService.getFilteredClients('displayName', 'ASC', false, typed).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.guardianOptions = Array.isArray(found) ? found.slice(0, 25) : [];
},
error: () => (this.guardianOptions = [])
});
});
}
/**
* Sub Type is picked from the DDS Account Type master. Once a bank runs a dozen schemes a plain
* dropdown is a scroll, so it filters on either the code or the scheme's name as you type.
*/
private wireSubTypeSearch(): void {
this.partyForm.get('subType')?.valueChanges.subscribe((value) => {
// A new account takes its product's rate, which the maturity preview is worked out on. The
// server sets the same rate when it saves; an account being edited already has it.
const scheme = this.subTypes.find((type) => type.accountTypeCode === value);
if (scheme && !this.partyId) {
this.partyForm.get('interestRate')?.setValue(Number(scheme.interestRate) || 0);
}
const term = String(value ?? '')
.toLowerCase()
.trim();
this.subTypeOptions = !term
? this.subTypes
: this.subTypes.filter(
(type) =>
String(type.accountTypeCode || '')
.toLowerCase()
.includes(term) ||
String(type.accountTypeDescription || '')
.toLowerCase()
.includes(term) ||
String(type.accountTypeGroup || '')
.toLowerCase()
.includes(term)
);
});
}
/** Opening the box should offer everything, not just what the current value happens to match. */
showAllSubTypes(): void {
this.subTypeOptions = this.subTypes;
}
displaySubType = (code: any): string => {
if (!code) {
return '';
}
const found = this.subTypes.find((type) => type.accountTypeCode === code);
return found ? `${found.accountTypeDescription} (${found.accountTypeCode})` : String(code);
};
// ----------------------------------------------------------- conditionals
private wireConditionalRules(): void {
this.partyForm.get('adultMinor')?.valueChanges.subscribe(() => this.applyAdultMinorRules());
this.partyForm.get('advCode')?.valueChanges.subscribe(() => this.applyAdvCodeRules());
this.partyForm.get('lien')?.valueChanges.subscribe(() => this.applyLienRules());
this.partyForm.get('lienReason')?.valueChanges.subscribe(() => this.applyLienRules());
this.partyForm.get('nomineeApplicable')?.valueChanges.subscribe(() => this.applyNomineeRules());
// Switching the split re-rules every nominee already on the form, not just the ones added after
// - a clerk who names three nominees and then picks Percentage must still be asked for shares.
this.partyForm.get('nomineeSplitMode')?.valueChanges.subscribe((mode) => {
this.nominees.controls.forEach((group) => this.applySplitModeTo(group, mode));
});
this.applyAdultMinorRules();
this.applyAdvCodeRules();
}
/**
* A minor's account has to say when the holder comes of age and who operates it until then; on an
* adult's account those four fields are meaningless, so they stay disabled and empty.
*/
private applyAdultMinorRules(): void {
const isMinor = this.partyForm.get('adultMinor')?.value === 'Minor';
for (const controlName of CreatePartyComponent.MINOR_FIELDS) {
const control = this.partyForm.get(controlName);
if (!control) {
continue;
}
if (isMinor) {
control.enable({ emitEvent: false });
// minAccountOpt is an optional note on how the account may be operated.
if (controlName !== 'minAccountOpt') {
control.setValidators(Validators.required);
}
} else {
control.clearValidators();
control.setValue('', { emitEvent: false });
control.disable({ emitEvent: false });
this.loadedGuardianName = '';
}
control.updateValueAndValidity({ emitEvent: false });
}
}
/**
* Transfer To Saving means the maturity proceeds land in a savings account, so the account has to
* name which one. Any other Adv Code leaves nothing to credit, and the three fields go away.
*/
private applyAdvCodeRules(): void {
const transfers = this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING;
for (const controlName of CreatePartyComponent.CREDIT_TO_FIELDS) {
const control = this.partyForm.get(controlName);
if (!control) {
continue;
}
if (transfers) {
control.setValidators(Validators.required);
} else {
control.clearValidators();
control.setValue('', { emitEvent: false });
}
control.updateValueAndValidity({ emitEvent: false });
}
}
get creditToRequired(): boolean {
return this.partyForm.get('advCode')?.value === CreatePartyComponent.TRANSFER_TO_SAVING;
}
get isMinorAccount(): boolean {
return this.partyForm.get('adultMinor')?.value === 'Minor';
}
get nomineesRequired(): boolean {
return this.partyForm.get('nomineeApplicable')?.value === 'Yes';
}
/**
* Which entry in the list a stored reason belongs to.
*
* Anything not on the list was typed under "Other", including reasons saved before there was a
* list - so it comes back as "Other" with the original words intact rather than being lost to a
* dropdown that has no room for it.
*/
private listedLienReason(stored: string | null | undefined): string {
if (!stored) {
return '';
}
return this.lienReasons.includes(stored) ? stored : 'Other';
}
/** True while this account's money is being held, which is what asks for a reason. */
get lienHeld(): boolean {
return this.partyForm?.get('lien')?.value === 'Yes';
}
/**
* A held account has to say what it is held for; a free one has nothing to explain.
*
* The reason is cleared when the hold is lifted rather than left behind, so the form never reads
* "Lien: No" beside an explanation of a hold that no longer exists - the server clears it for the
* same reason.
*/
private applyLienRules(): void {
const reason = this.partyForm.get('lienReason');
const other = this.partyForm.get('lienReasonOther');
if (!reason || !other) {
return;
}
if (this.lienHeld) {
reason.setValidators(Validators.required);
} else {
reason.clearValidators();
reason.setValue('', { emitEvent: false });
}
reason.updateValueAndValidity({ emitEvent: false });
// Only "Other" asks for words of its own - picking it and leaving it blank says no more than
// leaving the whole thing blank did.
if (this.lienHeld && this.lienReasonIsOther) {
other.setValidators([
Validators.required,
Validators.maxLength(200)
]);
} else {
other.clearValidators();
other.setValue('', { emitEvent: false });
}
other.updateValueAndValidity({ emitEvent: false });
}
/** True while the clerk has chosen to write the reason themselves. */
get lienReasonIsOther(): boolean {
return this.partyForm?.get('lienReason')?.value === 'Other';
}
/** Saying an account has a nominee but naming none would leave the nomination incomplete. */
private applyNomineeRules(): void {
if (this.nomineesRequired && this.nominees.length === 0) {
this.addNominee();
}
if (!this.nomineesRequired) {
this.nominees.clear();
}
}
// ----------------------------------------------------------- calculations
/**
* Maturity date and amount follow from the opening date and the tenure, so they are recomputed
* whenever one of those changes rather than being typed - which is why the legacy screen greyed
* them out.
*/
private wireMaturityCalculation(): void {
// The term drives everything downstream, so changing it refills Days as well.
for (const controlName of [
'openingDate',
'month'
]) {
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateTerm());
}
// Days typed by hand stands: it only moves the maturity date when no month was given.
this.partyForm.get('days')?.valueChanges.subscribe(() => {
this.recalculateMaturityDate();
this.recalculateMaturityAmount();
});
for (const controlName of [
'dailyDepositAmount',
'interestRate'
]) {
this.partyForm.get(controlName)?.valueChanges.subscribe(() => this.recalculateMaturityAmount());
}
}
private recalculateTerm(): void {
this.recalculateMaturityDate();
// Days is not refilled from the term: whether collection runs every day or skips Sundays and
// holidays is the bank's own rule, and guessing it would quietly put a wrong instalment count -
// and so a wrong maturity amount - in front of the customer. It is only checked, below.
this.partyForm.get('days')?.updateValueAndValidity({ emitEvent: false });
this.recalculateMaturityAmount();
}
private recalculateMaturityDate(): void {
const raw = this.partyForm.getRawValue();
const openingDate = this.toDate(raw.openingDate);
const months = Number(raw.month) || 0;
const days = Number(raw.days) || 0;
if (!openingDate || (months <= 0 && days <= 0)) {
this.partyForm.get('maturityDate')?.setValue('', { emitEvent: false });
return;
}
const maturity = new Date(openingDate.getTime());
if (months > 0) {
maturity.setMonth(maturity.getMonth() + months);
} else {
maturity.setDate(maturity.getDate() + days);
}
this.partyForm.get('maturityDate')?.setValue(maturity, { emitEvent: false });
}
/** Calendar days the term covers, once a term in months has been given. */
get termDays(): number | null {
const raw = this.partyForm.getRawValue();
const openingDate = this.toDate(raw.openingDate);
const months = Number(raw.month) || 0;
if (!openingDate || months <= 0) {
return null;
}
const maturity = new Date(openingDate.getTime());
maturity.setMonth(maturity.getMonth() + months);
return Math.round((maturity.getTime() - openingDate.getTime()) / (24 * 60 * 60 * 1000));
}
/**
* More instalments than there are days in the term is not a bank rule to be checked, it is
* arithmetic: 365 daily collections cannot happen inside three months. Left unchecked it puts a
* three-month maturity date beside a maturity amount worked out over a year, and the customer is
* quoted a figure the account will never reach.
*
* Fewer instalments than days is normal and passes silently - that is just a bank that does not
* collect on Sundays and holidays.
*/
private withinTerm(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const term = this.termDays;
const days = Number(control.value) || 0;
if (term === null || days <= 0 || days <= term) {
return null;
}
return { exceedsTerm: { term } };
};
}
private recalculateMaturityAmount(): void {
const raw = this.partyForm.getRawValue();
this.partyForm
.get('maturityAmount')
?.setValue(
this.maturityAmountOf(
Number(raw.dailyDepositAmount) || 0,
Number(raw.days) || 0,
Number(raw.interestRate) || 0
),
{ emitEvent: false }
);
}
/**
* Standard recurring-deposit simple interest: each daily instalment earns for the time left to
* maturity, so the first earns on the full term and the last on none. That averages out to
* n(n+1)/2 instalment-days of interest on top of the n instalments themselves.
*
* At a zero rate this is simply the total of the instalments.
*/
private maturityAmountOf(dailyAmount: number, days: number, ratePercent: number): number {
if (dailyAmount <= 0 || days <= 0) {
return 0;
}
const principal = dailyAmount * days;
const interest = ((dailyAmount * days * (days + 1)) / 2) * (ratePercent / 100) * (1 / 365);
return Math.round((principal + interest) * 100) / 100;
}
// ---------------------------------------------------------------- nominees
get nominees(): UntypedFormArray {
return this.partyForm.get('nominees') as UntypedFormArray;
}
private buildNomineeGroup(): UntypedFormGroup {
const group = this.formBuilder.group({
nominationDate: [
'',
Validators.required
],
name: [
'',
Validators.required
],
// A nominee the bank cannot reach, or whose age it does not know, is a name and nothing more -
// and the age is what decides whether a guardian has to be named at all.
mobileNo: [
'',
[
Validators.required,
Validators.pattern(CreatePartyComponent.MOBILE_RE)
]
],
birthDate: [
'',
Validators.required
],
relation: [
'',
Validators.required
],
gender: [
'',
Validators.required
],
guardianName: [''],
// One of these belongs to each split and neither to both: a share under Percentage, a place
// in the order under Sequence. Required-ness is set from the split mode rather than here, so
// switching the mode does not leave a stale rule behind.
sharePercent: [''],
sequenceNo: ['']
});
this.applySplitModeTo(group, this.partyForm?.get('nomineeSplitMode')?.value);
// A nominee who is a minor cannot receive the proceeds themselves, so a guardian is named.
// Driven by the date of birth, which is also what decides whether they are a minor at all. The
// clerk is asked for the date once and the rest follows, rather than being asked a second time
// in a way that can contradict the first.
const applyGuardianRule = () => {
const guardian = group.get('guardianName');
if (!guardian) {
return;
}
if (this.isNomineeMinor(group)) {
guardian.setValidators(Validators.required);
} else {
guardian.clearValidators();
guardian.setValue('', { emitEvent: false });
}
guardian.updateValueAndValidity({ emitEvent: false });
};
// Both dates decide it: being under age is the gap between them, so a correction to either can
// turn a nominee into a minor or out of one.
group.get('birthDate')?.valueChanges.subscribe(applyGuardianRule);
group.get('nominationDate')?.valueChanges.subscribe(applyGuardianRule);
return group;
}
addNominee(): void {
this.nominees.push(this.buildNomineeGroup());
}
removeNominee(index: number): void {
this.nominees.removeAt(index);
}
/** True while the deposit is being split by share rather than by order of entitlement. */
get splitByPercentage(): boolean {
return this.partyForm?.get('nomineeSplitMode')?.value === 'Percentage';
}
/** What the shares entered so far come to, so the clerk can see the remainder as they type. */
get nomineeShareTotal(): number {
return this.nominees.controls.reduce((sum, group) => sum + Number(group.get('sharePercent')?.value || 0), 0);
}
/**
* A nomination that does not account for the whole deposit names nobody for the remainder, so the
* form will not submit one. Shown as a running total rather than only on save, because a clerk
* who has typed four shares wants to know which one to change, not that the set is wrong.
*
* Silent until at least one share has been typed. A blank form has nothing wrong with it yet, and
* opening the tab to a red warning tells the clerk off for work they have not had a chance to do.
*/
get nomineeShareError(): string | null {
if (!this.splitByPercentage || this.nominees.length === 0 || !this.anyShareEntered) {
return null;
}
const total = this.nomineeShareTotal;
if (total === 100) {
return null;
}
const shortBy = Math.round((100 - total) * 100) / 100;
return shortBy > 0 ? `${shortBy}% still to allocate` : `${Math.abs(shortBy)}% over`;
}
/** Whether the clerk has started allocating at all, which is what turns the total into a warning. */
private get anyShareEntered(): boolean {
return this.nominees.controls.some((group) => String(group.get('sharePercent')?.value ?? '').trim() !== '');
}
/** True while the deposit passes down an order of entitlement rather than being split by share. */
get splitBySequence(): boolean {
return this.partyForm?.get('nomineeSplitMode')?.value === 'Sequence';
}
/**
* That every place in the order is filled exactly once.
*
* Two nominees given the same place leaves the bank to choose between them, and a gap leaves
* nobody at that place - both are the order failing to say what it exists to say. Checked across
* the whole list, because 1 and 3 are each perfectly valid on their own.
*/
get nomineeOrderError(): string | null {
if (!this.splitBySequence || this.nominees.length === 0) {
return null;
}
const entered = this.nominees.controls
.map((group) => String(group.get('sequenceNo')?.value ?? '').trim())
.filter((value) => value !== '')
.map(Number);
if (entered.length === 0) {
return null;
}
const duplicate = entered.find((value, index) => entered.indexOf(value) !== index);
if (duplicate !== undefined) {
return `Order ${duplicate} is used twice.`;
}
if (entered.length < this.nominees.length) {
return 'Every nominee needs a place in the order.';
}
const missing = Array.from({ length: this.nominees.length }, (_, i) => i + 1).filter((n) => !entered.includes(n));
return missing.length > 0 ? `Order ${missing.join(', ')} is missing.` : null;
}
/**
* Turns on whichever of the two the split actually uses, and clears the other.
*
* Leaving the unused one populated would send the server a share on a Sequence nomination, or an
* order on a Percentage one - a second answer to a question that split does not ask.
*/
private applySplitModeTo(group: AbstractControl, splitMode: string): void {
const share = group.get('sharePercent');
const order = group.get('sequenceNo');
const byPercentage = splitMode === 'Percentage';
if (share) {
if (byPercentage) {
share.setValidators([
Validators.required,
Validators.min(0.01),
Validators.max(100)
]);
} else {
share.clearValidators();
share.setValue('', { emitEvent: false });
}
share.updateValueAndValidity({ emitEvent: false });
}
if (order) {
if (byPercentage) {
order.clearValidators();
order.setValue('', { emitEvent: false });
} else {
order.setValidators([
Validators.required,
Validators.min(1)
]);
}
order.updateValueAndValidity({ emitEvent: false });
}
}
/**
* Whether this nominee was under age on the day they were nominated, worked out from their date
* of birth rather than from a separate Yes/No.
*
* Measured against the nomination date, not today, so the form agrees with what the server
* stores: a nominee who has since come of age was still a minor on the day they were named, and
* the guardian named beside them belongs to that day.
*/
isNomineeMinor(control: AbstractControl): boolean {
const birth = control.get('birthDate')?.value;
const nominated = control.get('nominationDate')?.value;
if (!birth || !nominated) {
return false;
}
const birthDate = new Date(birth);
const majorityOn = new Date(nominated);
majorityOn.setFullYear(majorityOn.getFullYear() - 18);
return birthDate > majorityOn;
}
// ------------------------------------------------------------------ actions
cancel(): void {
this.partyId = null;
this.partyStatus = 'ACTIVE';
this.linkedSavingsAccountId = null;
this.savingsAccountNo = null;
this.savingsProductName = null;
this.savingsAccountStatus = null;
this.savingsAccountBalance = null;
this.accountNo = null;
this.accountSchemeName = null;
this.accountBalance = null;
this.majorOn = null;
this.minorTurnedMajor = false;
this.loadedClientName = '';
this.loadedAgentName = '';
this.loadedGuardianName = '';
this.clientLookupFailed = false;
this.agentLookupFailed = false;
this.partyForm.reset();
this.partyForm.patchValue({
nominal: 'No',
operation: 'Self',
adultMinor: 'Adult',
lien: 'No',
lienReason: '',
isSmsActive: true,
isEmailActive: false,
freezeCode: 'Normal',
advCode: 'None',
nomineeApplicable: 'No',
nomineeSplitMode: 'Percentage',
currentBalance: 0,
openingBalance: 0,
maturityAmount: 0,
interestRate: 0
});
this.nominees.clear();
this.applyAdultMinorRules();
this.applyAdvCodeRules();
}
exit(): void {
this.router.navigate(['/accounts/dds']);
}
save(): void {
const raw = this.partyForm.getRawValue();
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const asDate = (value: any) => (value ? this.dateUtils.formatDate(value, dateFormat) : undefined);
const payload: any = {
// Either a client picked from the search list or an id typed straight in.
clientId: this.idOf(raw.clientId),
// idOf, not Number: the control now holds the picked agent object, and Number({}) is NaN.
agentId: this.idOf(raw.agentId),
officeId: raw.officeId,
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
subType: raw.subType,
lastAccount: raw.lastAccount || undefined,
nominal: raw.nominal === 'Yes',
aadharNo: raw.aadharNo || undefined,
partyName: raw.partyName,
partyName1: raw.partyName1 || undefined,
address1: raw.address1,
address2: raw.address2 || undefined,
address3: raw.address3 || undefined,
city: raw.city,
pincode: raw.pincode,
gender: raw.gender || undefined,
panCardNo: raw.panCardNo || undefined,
telephone: raw.telephone || undefined,
mobileNoSms: raw.mobileNoSms,
operation: raw.operation,
birthDate: asDate(raw.birthDate),
occupation: raw.occupation || undefined,
risk: raw.risk || undefined,
ckycId: raw.ckycId || undefined,
adultMinor: raw.adultMinor,
minorDate: asDate(raw.minorDate),
guardianClientId: raw.guardianClientId ? this.idOf(raw.guardianClientId) : undefined,
minAccountOpt: raw.minAccountOpt || undefined,
relation: raw.relation || undefined,
openingDate: asDate(raw.openingDate),
month: raw.month || undefined,
reopenDate: asDate(raw.reopenDate),
depositAmount: raw.depositAmount || undefined,
dailyDepositAmount: raw.dailyDepositAmount,
days: raw.days,
ledgerNo: raw.ledgerNo || undefined,
// No interest rate: the server gives the account its product's rate.
withdrawType: raw.withdrawType || undefined,
lien: raw.lien === 'Yes',
// Sent only when the money is actually held. Releasing it clears the reason on the
// server, so a released account does not carry an explanation of a hold it no
// longer has.
lienReason: raw.lien === 'Yes' ? (raw.lienReason === 'Other' ? raw.lienReasonOther : raw.lienReason) : undefined,
isSmsActive: !!raw.isSmsActive,
isEmailActive: !!raw.isEmailActive,
spDescription: raw.spDescription || undefined,
freezeCode: raw.freezeCode || undefined,
advCode: raw.advCode || undefined,
creditToBranch: raw.creditToBranch || undefined,
creditToAccountType: raw.creditToAccountType || undefined,
creditToAccount: raw.creditToAccount || undefined,
nomineeApplicable: raw.nomineeApplicable === 'Yes',
nomineeSplitMode: raw.nomineeSplitMode || undefined,
dateFormat,
locale,
nominees: (raw.nominees || []).map((nominee: any) => ({
// Sent through the same asDate as every other date on this form. A payload states one
// dateFormat and the server reads all of it that way, so a nominee date written in a second
// format is one the server is right to refuse.
nominationDate: asDate(nominee.nominationDate),
name: nominee.name,
mobileNo: nominee.mobileNo || undefined,
birthDate: asDate(nominee.birthDate),
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName || undefined,
// Only the one its split actually uses is sent. The other is left out entirely, so the
// record says it was not stated rather than carrying a zero the server would have to tell
// apart from a real entitlement or a real place in the order.
sharePercent: this.splitByPercentage ? Number(nominee.sharePercent) : undefined,
sequenceNo: this.splitBySequence ? Number(nominee.sequenceNo) : undefined
}))
};
const partyLabel = raw.partyCode || raw.partyName;
const done = (response: any) => {
// Under maker-checker the command is only recorded, and nothing has been saved yet.
if (!!response?.commandId && response?.resourceId == null) {
this.alertService.alert({
type: 'Sent for Approval',
message: this.partyId
? `Changes to party ${partyLabel} were sent for a manager's approval and will take effect once approved.`
: `New party ${partyLabel} was sent for a manager's approval and will be created once approved.`
});
}
this.exit();
};
if (this.partyId) {
this.organizationService.updateParty(this.partyId, payload).subscribe(done);
} else {
this.organizationService.createParty(payload).subscribe(done);
}
}
closeAccount(): void {
if (!this.partyId) {
return;
}
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(this.partyId, {
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
})
.subscribe(() => this.exit());
}
/**
* Whether this account can still be closed before its term is up.
*
* Only while the maturity date is ahead: once it has passed the account is paid out and closed on
* its own terms, and there is no shortfall to settle. The scheme's lock-in is deliberately not
* checked here - that period lives on the scheme, and a copy of the rule on this screen would drift
* from the one the server applies. An account inside its lock-in is offered the button and refused
* with the date it becomes closable, which tells the officer more than a missing button would.
*/
/**
* The client and agent as ids the API understands. Both fields are autocompletes, so each holds
* either the object picked from the list or the raw text typed into it - the dialog needs the
* client to look up which accounts can take the payout, and the agent's code only to name who is
* being charged.
*/
private get clientIdOfForm(): any {
const value = this.partyForm.get('clientId')?.value;
return value && typeof value === 'object' ? value.id : value;
}
private get agentCodeOfForm(): string {
const value = this.partyForm.get('agentId')?.value;
if (value && typeof value === 'object') {
return value.agentCode;
}
// The form holds the agent's id; the dialog names them by code.
const agent = (this.allAgents || []).find((option: any) => String(option.id) === String(value));
return agent?.agentCode ?? String(value ?? '');
}
get canCloseEarly(): boolean {
const maturity = this.partyForm.get('maturityDate')?.value;
if (!maturity) {
return false;
}
const matured = maturity instanceof Date ? maturity : new Date(maturity);
return !isNaN(matured.getTime()) && matured.getTime() > Date.now();
}
/**
* Closing early. The dialog asks the one thing the bank leaves open - whether the customer or the
* agent carries the cost - and shows what the scheme charges for each. Everything else, including
* the lock-in, is applied by the server.
*/
closeEarly(): void {
if (!this.partyId) {
return;
}
const dialogRef = this.dialog.open(PrematureCloseDialogComponent, {
data: {
partyCode: this.partyForm.get('partyCode')?.value,
partyName: this.partyForm.get('partyName')?.value,
subType: this.partyForm.get('subType')?.value,
interestRate: this.partyForm.get('interestRate')?.value,
maturityDate: this.partyForm.get('maturityDate')?.value,
currentBalance: this.partyForm.get('currentBalance')?.value,
openingDate: this.partyForm.get('openingDate')?.value,
clientId: this.clientIdOfForm,
agentCode: this.agentCodeOfForm
},
width: '560px',
autoFocus: false
});
dialogRef.afterClosed().subscribe((closeCommand: any) => {
if (!closeCommand) {
return;
}
const partyCode = this.partyForm.get('partyCode')?.value;
// Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass.
const withinLockin = !!closeCommand.lockinOverrideReason;
// The date goes up the way every other date on this system does - formatted to the user's own
// setting, with that setting and their locale alongside so the server reads it back the same
// way. An ordinary close sends it like this too; two ways of closing an account should not
// disagree about something as plain as what day it is.
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const request = {
...closeCommand,
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
};
(withinLockin
? this.organizationService.prematureCloseOverrideParty(this.partyId as number, request)
: this.organizationService.prematureCloseParty(this.partyId as number, request)
).subscribe((response: any) => {
// Under maker-checker the command is only recorded, and nothing has closed yet.
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
this.alertService.alert({
type: awaitingApproval ? 'Sent for Approval' : 'Party Closed',
message: awaitingApproval
? `Early closure of ${partyCode} is waiting for a manager's approval. The account stays open until then.`
: `${partyCode} is closed.`
});
this.exit();
});
// A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's
// own error handler, which names the reason. Repeating it here would say the same thing twice.
});
}
// ------------------------------------------------------------------ helpers
/**
* Splits a document type's name into lower-case words, so an identifier can be recognised by a
* whole word rather than a substring.
*
* Banks name these types freely - the records here carry "PAN Card" and "Aadhaar Card", while the
* code list also offers bare "PAN" and "AADHAAR" - so an exact-name match misses them. A substring
* match is worse: "COMPANY EMP ID" contains "pan", and would be filed as the customer's PAN.
*/
private wordsOf(name: any): string[] {
return String(name || '')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length > 0);
}
/**
* Indian digit grouping - 12,34,567.89 rather than 1,234,567.89. An officer reading the first
* says "twelve lakh" at a glance; the second has to be counted.
*/
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
/**
* A figure the system keeps rather than the officer types, shown as money: grouped, always to
* paise, and right-aligned so a column of them lines up. The control keeps the plain number.
*/
money(controlName: string): string {
const value = Number(this.partyForm.get(controlName)?.value);
return Number.isFinite(value) ? CreatePartyComponent.MONEY.format(value) : '';
}
/** A client field holds either the record picked from the search list or an id typed straight in. */
private idOf(value: any): number {
return Number(value && typeof value === 'object' ? value.id : value);
}
private setField(controlName: string, value: any): void {
if (value === null || value === undefined || value === '') {
return;
}
this.partyForm.get(controlName)?.setValue(String(value));
}
/** Fineract hands dates back as [yyyy, mm, dd] in places and as a string in others. */
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (value instanceof Date) {
return value;
}
if (Array.isArray(value) && value.length >= 3) {
return new Date(value[0], value[1] - 1, value[2]);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
private patchFormFromParty(party: any): void {
this.partyId = party.id;
this.partyStatus = party.status || 'ACTIVE';
this.linkedSavingsAccountId = party.savingsAccountId || null;
this.savingsAccountNo = party.savingsAccountNo || null;
this.savingsProductName = party.savingsProductName || null;
this.savingsAccountStatus = party.savingsAccountStatus || null;
this.savingsAccountBalance = party.savingsAccountBalance ?? null;
this.accountNo = party.accountNo || null;
this.accountSchemeName = party.subTypeDescription || party.subType || null;
this.accountBalance = party.currentBalance ?? null;
this.majorOn = party.majorOn || null;
this.minorTurnedMajor = party.minorTurnedMajor === true;
this.loadedClientName = party.clientName || '';
// Needed for the Credit To A/C dropdown to show the saved account as chosen rather than blank,
// so it is fetched on edit as well as when a client is first looked up.
if (party.clientId) {
this.loadCreditToAccounts(String(party.clientId));
}
this.loadedAgentName = party.agentName || '';
this.partyForm.patchValue({
clientId: party.clientId,
subType: party.subType,
agentId: party.agentId,
officeId: party.officeId,
partyCode: party.partyCode,
lastAccount: party.lastAccount,
nominal: party.nominal ? 'Yes' : 'No',
aadharNo: party.aadharNo,
partyName: party.partyName,
partyName1: party.partyName1,
address1: party.address1,
address2: party.address2,
address3: party.address3,
city: party.city,
pincode: party.pincode,
gender: party.gender,
panCardNo: party.panCardNo,
telephone: party.telephone,
mobileNoSms: party.mobileNoSms,
operation: party.operation,
birthDate: this.toDate(party.birthDate),
occupation: party.occupation,
risk: party.risk,
ckycId: party.ckycId,
adultMinor: party.adultMinor,
minorDate: this.toDate(party.minorDate),
guardianClientId: party.guardianClientId,
minAccountOpt: party.minAccountOpt,
relation: party.relation,
openingDate: this.toDate(party.openingDate),
month: party.month,
maturityDate: this.toDate(party.maturityDate),
reopenDate: this.toDate(party.reopenDate),
depositAmount: party.depositAmount,
dailyDepositAmount: party.dailyDepositAmount,
days: party.days,
ledgerNo: party.ledgerNo,
maturityAmount: party.maturityAmount,
interestRate: party.interestRate,
withdrawType: party.withdrawType,
od1Flag: party.od1Flag,
od2Flag: party.od2Flag,
lien: party.lien ? 'Yes' : 'No',
lienReason: this.listedLienReason(party.lienReason),
lienReasonOther: this.listedLienReason(party.lienReason) === 'Other' ? party.lienReason : '',
payDate: this.toDate(party.payDate),
paymentAmount: party.paymentAmount,
currentBalance: party.currentBalance,
openingBalance: party.openingBalance,
isSmsActive: party.isSmsActive,
isEmailActive: party.isEmailActive,
interestCalc: party.interestCalc,
interestDate: this.toDate(party.interestDate),
spDescription: party.spDescription,
freezeCode: party.freezeCode,
advCode: party.advCode,
creditToBranch: party.creditToBranch,
creditToAccountType: party.creditToAccountType,
creditToAccount: party.creditToAccount,
nomineeApplicable: party.nomineeApplicable ? 'Yes' : 'No',
nomineeSplitMode: party.nomineeSplitMode
});
this.nominees.clear();
(party.nominees || []).forEach((nominee: any) => {
const group = this.buildNomineeGroup();
group.patchValue({
nominationDate: this.toDate(nominee.nominationDate),
name: nominee.name,
mobileNo: nominee.mobileNo,
birthDate: this.toDate(nominee.birthDate),
relation: nominee.relation,
gender: nominee.gender,
guardianName: nominee.guardianName,
sharePercent: nominee.sharePercent,
sequenceNo: nominee.sequenceNo
});
this.nominees.push(group);
});
this.applyAdultMinorRules();
this.applyAdvCodeRules();
// A closed party is a record to look at, not to change.
if (this.isClosed) {
this.partyForm.disable({ emitEvent: false });
}
}
}
/** FEMALE or female -> Female; each word, so "SELF EMPLOYED" -> "Self Employed". */
function titleCase(value: any): string {
return String(value ?? '')
.toLowerCase()
.replace(/(^|\s)(\S)/g, (_match, space, letter) => space + letter.toUpperCase())
.trim();
}