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