/** * 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(); 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(); }