/** * 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, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormArray, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; /** Custom Services */ import { AccountingService } from '../accounting.service'; import { SettingsService } from 'app/settings/settings.service'; import { PopoverService } from '../../configuration-wizard/popover/popover.service'; import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service'; /** Custom Validators */ import { onlyOneOfTheFieldsIsRequiredValidator } from './only-one-of-the-fields-is-required.validator'; import { Dates } from 'app/core/utils/dates'; import { TranslateService } from '@ngx-translate/core'; import { CurrencyPipe } from '@angular/common'; import { GlAccountDisplayComponent } from '../../shared/accounting/gl-account-display/gl-account-display.component'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** * Migrate opening balances component. */ @Component({ selector: 'mifosx-migrate-opening-balances', templateUrl: './migrate-opening-balances.component.html', styleUrls: ['./migrate-opening-balances.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, GlAccountDisplayComponent, CurrencyPipe, MatIconModule, MatTooltipModule ] }) export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { private formBuilder = inject(UntypedFormBuilder); private accountingService = inject(AccountingService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); private route = inject(ActivatedRoute); private router = inject(Router); private configurationWizardService = inject(ConfigurationWizardService); private popoverService = inject(PopoverService); private translateService = inject(TranslateService); /** Minimum opening balances date allowed. */ minDate = new Date(2000, 0, 1); /** Maximum opening balances date allowed. */ maxDate = new Date(); /** Opening balances form. */ openingBalancesForm: UntypedFormGroup; /** Opening balances data. */ openingBalancesData: any; /** Office data. */ officeData: any; /** Currency data. */ currencyData: any; currencyCode: string; /** Sum total of debits. */ debitsSum = 0; /** Sum total of credits. */ creditsSum = 0; /** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the * account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility * but locked against editing: redefining it as a bare number here, instead of through a counted * denomination breakdown, would let the GL figure drift away from what is physically in the till. * The backend enforces the same rule independently (see defineOpeningBalance's * validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */ denominationManagedGlAccountIds: number[] = []; /* Reference of search form */ @ViewChild('searchFormRef') searchFormRef: ElementRef; /* Template for popover on search form */ @ViewChild('templateSearchFormRef') templateSearchFormRef: TemplateRef; /** * Retrieves the offices and currencies from `resolve`. * @param {FormBuilder} formBuilder Form Builder. * @param {AccountingService} accountingService Accounting Service. * @param {SettingsService} settingsService Settings Service. * @param {ActivatedRoute} route Activated Route. * @param {Router} router Router for navigation. * @param {ConfigurationWizardService} configurationWizardService ConfigurationWizard Service. * @param {PopoverService} popoverService PopoverService. */ constructor() { this.route.data.subscribe((data: { offices: any; currencies: any }) => { this.officeData = data.offices; this.currencyData = data.currencies.selectedCurrencyOptions; }); // Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at // Teller, so their row can be locked the moment the office's accounts are drawn. this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => { this.denominationManagedGlAccountIds = (mappings || []) .filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102) .map((mapping) => mapping.glAccountData?.id) .filter((id) => id != null); // Retrieve may already have run before this (fast) request came back — lock any row it // missed rather than leaving it editable for the rest of this visit. this.glAccountEntries?.controls.forEach((control) => { const glAccountId = control.get('glAccountId')?.value; if (this.isDenominationManaged(glAccountId) && control.enabled) { control.disable(); } }); }); } /** * Creates the opening balances form. (initially retrieves gl accounts on the basis of specified office) */ ngOnInit() { this.maxDate = this.settingsService.businessDate; this.createOpeningBalancesForm(); } /** * Creates the opening balances form. */ createOpeningBalancesForm() { this.openingBalancesForm = this.formBuilder.group({ officeId: [ '', Validators.required ], currencyCode: [ '', Validators.required ], transactionDate: [ '', Validators.required ], glAccountEntries: this.formBuilder.array([]) }); this.openingBalancesForm.controls.currencyCode.valueChanges.subscribe((value: string) => { this.currencyCode = value; }); } /** * Creates the gl account entry form. * @param glAccount GL Account for which form is returned. * @returns {FormGroup} GL Account entry form. */ createGLAccountEntryForm(glAccount: any): UntypedFormGroup { // A never-touched account arrives as a placeholder row (amount/entryType both null); one that // already carries an opening balance arrives with its real, committed figure — show that // instead of leaving the field looking blank. const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null; const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null; const group = this.formBuilder.group( { glAccountId: [glAccount.glAccountId], debit: [existingDebit], credit: [existingCredit] }, { validator: onlyOneOfTheFieldsIsRequiredValidator } ); if (this.isDenominationManaged(glAccount.glAccountId)) { // Disabling excludes this row from the form's own .value, so it is never resubmitted here — // the account keeps whatever Migrate Cash Denomination last set it to. group.disable(); } return group; } /** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see * denominationManagedGlAccountIds. */ isDenominationManaged(glAccountId: number): boolean { return this.denominationManagedGlAccountIds.includes(glAccountId); } /** * Gets the gl account entries form array. * @returns {FormArray} GL Account entries form array. */ get glAccountEntries(): UntypedFormArray { return this.openingBalancesForm.get('glAccountEntries') as UntypedFormArray; } /** * Retrieves gl accounts on the basis of specified office. */ retrieveOpeningBalances() { this.accountingService .retrieveOpeningBalances(this.openingBalancesForm.value.officeId) .subscribe((openingBalancesData: any) => { const entry = this.openingBalancesForm.get('glAccountEntries') as UntypedFormArray; openingBalancesData.glAccounts = openingBalancesData.assetAccountOpeningBalances.concat( openingBalancesData.liabityAccountOpeningBalances, openingBalancesData.equityAccountOpeningBalances, openingBalancesData.incomeAccountOpeningBalances, openingBalancesData.expenseAccountOpeningBalances ); openingBalancesData.glAccounts.forEach((glAccount: any) => { entry.push(this.createGLAccountEntryForm(glAccount)); }); this.openingBalancesData = openingBalancesData; entry.valueChanges.subscribe(() => this.recomputeSums()); // Runs once immediately too: valueChanges only fires on a later edit, and the locked cash // row's own prefilled figure (set once, above, never through a further value change) would // otherwise never be reflected in the banner at all. this.recomputeSums(); }); } /** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is * excluded here exactly as it is excluded from the form's own .value used at submit time — this * screen only needs to balance the accounts it is itself about to (re)define, not the one * Migrate Cash Denomination already settled and this form can never touch. */ private recomputeSums(): void { this.debitsSum = 0; this.creditsSum = 0; this.glAccountEntries.controls.forEach((control) => { if (control.disabled) { return; } this.debitsSum += control.value.debit || 0; this.creditsSum += control.value.credit || 0; }); } /** * Submits the opening balances form and defines opening balances, * if successful redirects to view created transaction. */ submit() { if (this.amountsAreOK()) { const openingBalances = this.openingBalancesForm.value; openingBalances.locale = this.settingsService.language.code; openingBalances.dateFormat = this.settingsService.dateFormat; if (openingBalances.transactionDate instanceof Date) { openingBalances.transactionDate = this.dateUtils.formatDate( openingBalances.transactionDate, this.settingsService.dateFormat ); } openingBalances.debits = []; openingBalances.credits = []; this.openingBalancesForm.value.glAccountEntries.forEach((entry: any) => { if (entry.debit) { openingBalances.debits.push({ glAccountId: entry.glAccountId, amount: entry.debit }); } if (entry.credit) { openingBalances.credits.push({ glAccountId: entry.glAccountId, amount: entry.credit }); } }); delete openingBalances.glAccountEntries; this.accountingService.defineOpeningBalances(openingBalances).subscribe((response: any) => { this.router.navigate([ '/accounting/journal-entries/transactions/view', response.transactionId ]); }); } } /** * Popover function * @param template TemplateRef. * @param target HTMLElement | ElementRef. * @param position String. * @param backdrop Boolean. */ showPopover( template: TemplateRef, target: HTMLElement | ElementRef, position: string, backdrop: boolean ): void { setTimeout(() => this.popoverService.open(template, target, position, backdrop, {}), 200); } /** * To show popover. */ ngAfterViewInit() { if (this.configurationWizardService.showMigrateOpeningBalances) { setTimeout(() => { this.showPopover(this.templateSearchFormRef, this.searchFormRef.nativeElement, 'bottom', true); }); } } /** * Next Step (Closing Entries Accounting Page) Configuration Wizard. */ nextStep() { this.configurationWizardService.showMigrateOpeningBalances = false; this.configurationWizardService.showClosingEntries = true; this.router.navigate(['/accounting']); } /** * Previous Step (Migrate Opening Balances Accounting Page) Configuration Wizard. */ previousStep() { this.router.navigate(['/accounting']); } amountsAreOK(): boolean { let debitsSum = 0; let creditsSum = 0; this.openingBalancesForm.value.glAccountEntries.forEach((entry: any) => { if (entry.debit) { debitsSum = debitsSum + entry.debit; } if (entry.credit) { creditsSum = creditsSum + entry.credit; } }); return debitsSum > 0 && debitsSum === creditsSum; } glAccountTypeLabel(accountType: string): string { return this.translateService.instant('labels.inputs.accounting.' + accountType); } }