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

231 lines
9.0 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
/** Custom Pipes */
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* Where a matured DDS account's money should go.
*
* Two destinations, and the difference is whether the money leaves the bank. Another account of the
* customer's here is a move across the books. Anywhere else means a transfer out, which clears later
* or fails - so the money is parked in the matured-deposits payable account first and the branch
* sends it on from there, rather than being launched into a gap where it has left one account and
* arrived at none.
*
* The amount is not asked for. It is whatever the account holds, worked out by the server: a figure
* typed here could pay out more than the customer has, or quietly leave some behind.
*/
@Component({
selector: 'mifosx-payout-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatRadioModule,
MatSelectModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
DateFormatPipe
],
templateUrl: './payout-dialog.component.html',
styleUrls: ['./payout-dialog.component.scss']
})
export class PayoutDialogComponent implements OnInit {
private organizationService = inject(OrganizationService);
private dialogRef = inject(MatDialogRef<PayoutDialogComponent>);
data = inject<any>(MAT_DIALOG_DATA);
/** 'own' moves it to another account here; 'other' parks it for a transfer to another bank. */
destination: 'own' | 'other' = 'own';
/**
* The customer's accounts this maturity can be paid into.
*
* Worked out by the server, which applies the same conditions the payout enforces: an active
* savings account of theirs, not a fixed or recurring deposit, and not one of their DDS scheme
* accounts. Filtering here instead let the two drift - the list once offered a scheme account
* belonging to a party that had already closed, which would have stranded the money.
*/
ownAccounts: any[] = [];
selectedAccountNo = '';
/** Where the balance is parked when the customer has no account here. */
sundryAccounts: any[] = [];
sundryGlAccountId: number | null = null;
/** What is typed in the GL box - a code or part of a name. */
sundrySearch = '';
/** The accounts matching what is typed, by GL code or name, in any case. */
get sundryOptions(): any[] {
const typed = this.sundrySearch.trim().toLowerCase();
if (!typed || typed === this.sundryLabel(this.chosenSundry).toLowerCase()) {
return this.sundryAccounts;
}
return this.sundryAccounts.filter(
(account: any) =>
String(account.glCode).toLowerCase().includes(typed) ||
String(account.name || '')
.toLowerCase()
.includes(typed)
);
}
get chosenSundry(): any {
return this.sundryAccounts.find((account: any) => account.id === this.sundryGlAccountId);
}
/** Shows a picked account in the box as its code and name. */
readonly sundryDisplay = (value: any): string =>
value && typeof value === 'object' ? this.sundryLabel(value) : value || '';
sundryLabel(account: any): string {
return account ? `${account.glCode} · ${account.name}` : '';
}
pickSundry(account: any): void {
this.sundryGlAccountId = account ? account.id : null;
this.sundrySearch = this.sundryLabel(account);
}
/** Typing again clears the choice until an account is picked, so a half-typed code is never sent. */
onSundryTyped(value: any): void {
// Picking from the list hands over the account itself rather than text.
if (value && typeof value === 'object') {
this.pickSundry(value);
return;
}
value = String(value ?? '');
this.sundrySearch = value;
if (value !== this.sundryLabel(this.chosenSundry)) {
const exact = this.sundryAccounts.find(
(account: any) => String(account.glCode).toLowerCase() === value.trim().toLowerCase()
);
this.sundryGlAccountId = exact ? exact.id : null;
}
}
loading = true;
/**
* How the amount payable was made up - what was deposited, what came in or went out by transfer,
* and the interest - read from the account's own transactions so the officer can explain the
* figure to the customer line by line. Null until the transactions have arrived.
*/
breakdown: {
deposits: number;
transfersIn: number;
transfersOut: number;
interest: number;
other: number;
interestToPost: number;
total: number;
} | null = null;
ngOnInit(): void {
// The accounts the balance can be parked in. None is chosen: each bank has its own Sundry account,
// and the clerk searches for it and picks it.
this.organizationService.getDdsSundryAccounts().subscribe({
next: (accounts: any) => (this.sundryAccounts = accounts || []),
error: () => (this.sundryAccounts = [])
});
this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({
next: (transactions: any) => (this.breakdown = this.breakdownOf(Array.isArray(transactions) ? transactions : [])),
error: () => (this.breakdown = null)
});
this.organizationService.getCreditableAccounts(this.data.clientId).subscribe({
next: (accounts: any) => {
this.ownAccounts = accounts || [];
// Nothing here to pay into, so the only real choice is a transfer out - offered as the
// default rather than leaving the officer on an option that cannot be completed.
if (this.ownAccounts.length === 0) {
this.destination = 'other';
} else if (this.ownAccounts.length === 1) {
this.selectedAccountNo = this.ownAccounts[0].accountNo;
}
this.loading = false;
},
error: () => {
this.ownAccounts = [];
this.destination = 'other';
this.loading = false;
}
});
}
/** Interest is still to be worked out for part of the term, and the payout will add it. */
get interestStillToCome(): boolean {
return (
!!this.data.interestDate &&
!!this.data.maturityDate &&
String(this.data.interestDate) < String(this.data.maturityDate)
);
}
private breakdownOf(transactions: any[]) {
const sum = (test: (txn: any) => boolean) =>
transactions.filter(test).reduce((total: number, txn: any) => total + Number(txn.amount || 0), 0);
const signed = (type: string) =>
sum((txn) => txn.transactionType === type && txn.direction === 'CREDIT') -
sum((txn) => txn.transactionType === type && txn.direction === 'DEBIT');
const isTransfer = (txn: any) => txn.transactionType === 'WITHDRAW_DEPOSITE' || txn.transactionType === 'TRANSFER';
const deposits = signed('PARTY_TRAN');
const transfersIn = sum((txn) => isTransfer(txn) && txn.direction === 'CREDIT');
const transfersOut = sum((txn) => isTransfer(txn) && txn.direction === 'DEBIT');
const interest =
signed('INTEREST_POST') +
signed('OD_INTEREST_POST') +
signed('PREMATURE_AGENT_TOP_UP') +
signed('PREMATURE_INT_RECOVERY');
const balance = Number(this.data.currentBalance || 0);
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
// still add up to the balance rather than leaving the officer a gap to explain.
const other = Math.round((balance - (deposits + transfersIn - transfersOut + interest)) * 100) / 100;
// Interest already worked out but not yet credited is posted by the payout itself, first.
const interestToPost = Number(this.data.interestCalc || 0);
return { deposits, transfersIn, transfersOut, interest, other, interestToPost, total: balance + interestToPost };
}
get canPayOut(): boolean {
if (this.loading) {
return false;
}
return this.destination === 'other' ? !!this.sundryGlAccountId : !!this.selectedAccountNo;
}
payOut(): void {
this.dialogRef.close(
this.destination === 'own'
? { toSavingsAccountNo: this.selectedAccountNo }
: { sundryGlAccountId: this.sundryGlAccountId }
);
}
}