/** * 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/. */ import { Component, OnInit, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { NgClass } from '@angular/common'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FormatNumberPipe } from 'app/pipes/format-number.pipe'; import { OrganizationService } from 'app/organization/organization.service'; /** What each DDS transaction type is called on screen. */ const TRANSACTION_LABELS: Record = { PARTY_TRAN: 'Collection', INTEREST_POST: 'Interest', OD_INTEREST_POST: 'OD Interest', MATURITY_PAYOUT: 'Maturity Payout', PREMATURE_PAYOUT: 'Premature Payout', PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)', PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent', WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)', TRANSFER: 'Transfer' }; /** * One DDS account and everything that has moved on it, as the savings account page shows a savings * account: the account's terms and where it stands, then its transactions with the balance after * each, narrowed to a day or a range. * * Read-only. The account is changed from DDS Party Master; its money moves through collections, the * cashier and the batch runs. */ @Component({ selector: 'mifosx-dds-account-view', templateUrl: './dds-account-view.component.html', styleUrls: ['./dds-account-view.component.scss'], standalone: true, imports: [ ...STANDALONE_SHARED_IMPORTS, ReactiveFormsModule, NgClass, FaIconComponent, MatTooltipModule, MatProgressSpinnerModule, FormatNumberPipe ] }) export class DdsAccountViewComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private organizationService = inject(OrganizationService); isLoading = true; account: any = null; private transactions: any[] = []; /** The page's tabs, as the savings account page has them - only those that apply to DDS. */ tab: 'general' | 'transactions' | 'passbook' | 'nominees' | 'closure' = 'general'; /** How a closed account was closed - read only for closed accounts, for the Closure tab. */ closure: any = null; /** What each kind of closure is called on screen. */ readonly closureLabels: Record = { PREMATURE: 'Premature Close', MATURITY_PAYOUT_AND_CLOSE: 'Maturity Payout & Close', MATURITY_PAYOUT_THEN_CLOSE: 'Maturity Payout, then Closed', MATURITY_AUTO_CLOSE: 'Maturity Auto Close (night job)', CLOSED_NIL_BALANCE: 'Closed with nil balance' }; /** The passbook reads oldest first, as it is written up. */ get passbookEntries(): any[] { return [...this.shownTransactions].reverse(); } /** The balance after the last entry shown. */ get passbookClosingBalance(): number { const entries = this.passbookEntries; return entries.length > 0 ? Number(entries[entries.length - 1].balanceAfterTransaction || 0) : 0; } /** * Prints the passbook alone. It is copied into a window of its own with only its own styles, so the * page's menus, banner and tabs do not come out on the paper. */ printPassbook(): void { const passbook = document.getElementById('dds-passbook'); const popup = window.open('', '_blank', 'width=900,height=700'); if (!passbook || !popup) { return; } popup.document.write(`${this.account?.accountNo || 'DDS'} Passbook${passbook.innerHTML}`); popup.document.close(); popup.focus(); popup.print(); } fromDateControl = new FormControl(''); toDateControl = new FormControl(''); readonly today = new Date().toISOString().split('T')[0]; ngOnInit(): void { const partyId = this.route.snapshot.paramMap.get('partyId'); if (!partyId) { this.isLoading = false; return; } forkJoin({ account: this.organizationService.getParty(partyId).pipe(catchError(() => of(null))), transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([]))) }).subscribe(({ account, transactions }) => { this.account = account; if (account?.status === 'CLOSED') { this.organizationService.getPartyClosure(partyId).subscribe({ next: (closure: any) => (this.closure = closure), error: () => (this.closure = null) }); } // Newest first, and within a day the later entry first - the order a passbook is read back in. this.transactions = (Array.isArray(transactions) ? transactions : []).sort( (a: any, b: any) => String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id) ); this.isLoading = false; }); } /** The transactions in the chosen range - all of them when no range is set. */ get shownTransactions(): any[] { const from = this.fromDateControl.value; const to = this.toDateControl.value; return this.transactions.filter((txn: any) => { const day = String(txn.transactionDate || ''); if (from && day < String(from)) { return false; } if (to && day > String(to)) { return false; } return true; }); } get totalCredits(): number { return this.sumOf('CREDIT'); } get totalDebits(): number { return this.sumOf('DEBIT'); } private sumOf(direction: string): number { return this.shownTransactions .filter((txn: any) => txn.direction === direction) .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); } /** Everything of one type ever credited, whatever the date filter says - for the summary cards. */ totalOf(type: string): number { return this.transactions .filter((txn: any) => txn.transactionType === type && txn.direction === 'CREDIT') .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); } /** Interest credited, less any taken back on an early closure. */ get interestEarned(): number { const recovered = this.transactions .filter((txn: any) => txn.transactionType === 'PREMATURE_INT_RECOVERY') .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); return ( this.totalOf('INTEREST_POST') + this.totalOf('OD_INTEREST_POST') + this.totalOf('PREMATURE_AGENT_TOP_UP') - recovered ); } /** Money that has left the account - payouts, withdrawals, transfers out. */ get totalDebitsAll(): number { return this.transactions .filter((txn: any) => txn.direction === 'DEBIT' && txn.transactionType !== 'PREMATURE_INT_RECOVERY') .reduce((sum: number, txn: any) => sum + Number(txn.amount || 0), 0); } get lastTransactionDate(): string | null { return this.transactions.length > 0 ? this.transactions[0].transactionDate : null; } /** The rows on screen, as a spreadsheet the branch can keep or send on. */ exportCsv(): void { const header = [ '#', 'ID', 'Transaction Date', 'Transaction Type', 'Voucher', 'Description', 'Account', 'Debit', 'Credit', 'Balance' ]; const quote = (value: any) => `"${String(value ?? '').replace(/"/g, '""')}"`; const lines = this.shownTransactions.map((txn: any, index: number) => [ index + 1, txn.id, txn.transactionDate, this.label(txn.transactionType), txn.voucherNo, txn.description, txn.accountNo, txn.direction === 'DEBIT' ? txn.amount : '', txn.direction === 'CREDIT' ? txn.amount : '', txn.balanceAfterTransaction ] .map(quote) .join(',') ); const blob = new Blob( [[ header.map(quote).join(','), ...lines ].join('\r\n')], { type: 'text/csv;charset=utf-8' } ); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${this.account?.accountNo || 'dds-account'}-transactions.csv`; link.click(); URL.revokeObjectURL(link.href); } get hasDateFilters(): boolean { return !!(this.fromDateControl.value || this.toDateControl.value); } showToday(): void { this.fromDateControl.setValue(this.today); this.toDateControl.setValue(this.today); } clearDateFilters(): void { this.fromDateControl.setValue(''); this.toDateControl.setValue(''); } label(type: string): string { return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' '); } statusClass(): string { switch (this.account?.status) { case 'ACTIVE': return 'status--active'; case 'CLOSED': return 'status--closed'; default: return 'status--default'; } } back(): void { this.router.navigate(['/accounts/dds-accounts']); } openInPartyMaster(): void { this.router.navigate([ '/accounts/dds', this.account.id, 'edit' ]); } }