The Daily Transaction Entry screen no longer composes a Party Tran. An agent records their own round elsewhere; this screen reads back what they wrote down, shows what it adds up to, and settles the day once a clerk has counted the cash against it. Nothing is typed here and nothing is edited - what settles is what the entries say, to the rupee. Agent Master now asks which ledger account the agent's collections pass through, one agent to an account, and offers only the accounts that can carry them. The DDS scheme product is kept out of the agent's own savings list for the same reason the server refuses it: debits are blocked on those accounts, so commission paid into one would go in and never come out. The DDS product carries a Maturity Auto Close setting, asked as Yes/No to match the Auto Interest Post question directly above it, and Batch Processing offers the run that applies it. Also: the report viewer stops printing two money columns ungrouped and stops totalling a column that holds a word, and an agent's birth date cannot be picked later than eighteen years ago. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1765 lines
62 KiB
TypeScript
1765 lines
62 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';
|
|
|
|
/** 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 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;
|
|
|
|
/**
|
|
* 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'
|
|
];
|
|
|
|
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.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 {
|
|
this.clientsService.getFilteredClients('displayName', 'ASC', false, term).subscribe({
|
|
next: (response: any) => {
|
|
const found = response?.pageItems || response || [];
|
|
this.showingClientList = false;
|
|
this.clientOptions = Array.isArray(found) ? found.slice(0, 25) : [];
|
|
},
|
|
error: () => (this.clientOptions = [])
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.setField('partyName', client?.displayName);
|
|
this.setField('mobileNoSms', client?.mobileNo);
|
|
this.setField('gender', client?.gender?.name || client?.gender);
|
|
const dateOfBirth = this.toDate(client?.dateOfBirth);
|
|
if (dateOfBirth) {
|
|
this.partyForm.get('birthDate')?.setValue(dateOfBirth);
|
|
}
|
|
// 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.setField('aadharNo', documentKey);
|
|
} else if (words.includes('pan')) {
|
|
this.setField('panCardNo', documentKey);
|
|
} else if (words.includes('ckyc')) {
|
|
this.setField('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);
|
|
};
|
|
|
|
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.setField('address1', address.addressLine1 || address.street);
|
|
this.setField('address2', address.addressLine2);
|
|
this.setField('address3', address.addressLine3);
|
|
this.setField('city', address.city);
|
|
this.setField('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) => {
|
|
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.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,
|
|
interestRate: raw.interestRate || 0,
|
|
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
|
|
}))
|
|
};
|
|
|
|
if (this.partyId) {
|
|
this.organizationService.updateParty(this.partyId, payload).subscribe(() => this.exit());
|
|
} else {
|
|
this.organizationService.createParty(payload).subscribe(() => this.exit());
|
|
}
|
|
}
|
|
|
|
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(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;
|
|
return value && typeof value === 'object' ? value.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,
|
|
clientId: this.clientIdOfForm,
|
|
agentCode: this.agentCodeOfForm
|
|
},
|
|
width: '560px',
|
|
autoFocus: false
|
|
});
|
|
|
|
dialogRef.afterClosed().subscribe((closeCommand: any) => {
|
|
if (!closeCommand) {
|
|
return;
|
|
}
|
|
// 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;
|
|
this.organizationService
|
|
.prematureCloseParty(this.partyId as number, {
|
|
...closeCommand,
|
|
closeDate: this.dateUtils.formatDate(new Date(), dateFormat),
|
|
dateFormat,
|
|
locale
|
|
})
|
|
.subscribe(() => 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.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 });
|
|
}
|
|
}
|
|
}
|