231 lines
9.0 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|