/** * 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { MatTooltip } from '@angular/material/tooltip'; import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { DdsDateAdapter, DDS_DATE_FORMATS } from './dds-date-adapter'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; import { AlertService } from 'app/core/alert/alert.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; /** * DDS Interest & Commission batch processing — the legacy Interest Part menu. * * Eight menu entries, which are four Calculate/Post pairs. A *calculation* job works out what has * been earned since the last one and parks it: interest against the customer's account, commission * against the agent. Neither is spendable yet. The matching *posting* job then moves that parked * figure where it belongs — into the customer's balance, or out to the agent. * * Every job is itself a two-step Generate then Post. Generate saves what it worked out and changes * nothing else, so a figure that looks wrong is discarded by simply not posting it. */ @Component({ selector: 'mifosx-dds-batch-processing', templateUrl: './dds-batch-processing.component.html', styleUrls: ['./dds-batch-processing.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, // The reason a run failed is too long to sit in the column, and too useful to leave out. MatTooltip ], // Day-first dates, as the legacy screen showed them. Declared here rather than application-wide: // a @Component's providers reach only this component, so no other screen's datepicker changes. providers: [ { provide: DateAdapter, useClass: DdsDateAdapter }, { provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS } ] }) export class DdsBatchProcessingComponent implements OnInit { private formBuilder = inject(UntypedFormBuilder); private router = inject(Router); private organizationService = inject(OrganizationService); private alertService = inject(AlertService); private dialog = inject(MatDialog); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); /** * The menu as the legacy system lists it, paired with what the API calls each job. Ordered as * calculate-then-post within each pair, matching the numbering on the legacy screen. */ jobTypes: Array<{ label: string; code: string }> = [ { label: 'Interest Calculation', code: 'INTEREST_CALCULATION' }, { label: 'Interest Posting', code: 'INTEREST_POSTING' }, { label: 'OD Interest Calculation', code: 'OD_INTEREST_CALCULATION' }, { label: 'OD Interest Posting', code: 'OD_INTEREST_POSTING' }, { label: 'Commission Calculation', code: 'COMMISSION_CALCULATION' }, { label: 'Commission Posting', code: 'COMMISSION_POSTING' }, // Last, and on its own: the others move money onto accounts that stay open, while this one pays // an account out and shuts it. Listing it beside a calculate/post pair would suggest it is the // second half of one. { label: 'Maturity Auto Close', code: 'MATURITY_AUTO_CLOSE' } ]; branchScopes = [ 'All', 'Selected' ]; private static readonly MONEY = new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); offices: any[] = []; selectedOfficeNames = new Set(); /** The run currently previewed on screen, or null once it has been posted or discarded. */ generatedRun: any = null; previewRows: any[] = []; recentRuns: any[] = []; batchForm: UntypedFormGroup = this.formBuilder.group({ jobType: [ 'INTEREST_CALCULATION', Validators.required ], branchScope: [ 'All', Validators.required ], jobDate: [ new Date(), Validators.required ], agentFrom: [ 1, Validators.required ], agentTo: [ 99999, Validators.required ], partyFrom: [ 1, Validators.required ], partyTo: [ 99999999, Validators.required ] }); get jobType(): string { return this.batchForm.get('jobType').value; } get jobTypeLabel(): string { return this.jobTypes.find((type) => type.code === this.jobType)?.label ?? this.jobType; } /** Commission is per agent, so the party range does not narrow it. */ get isCommissionJob(): boolean { return this.jobType.startsWith('COMMISSION'); } get isPostingJob(): boolean { return this.jobType.endsWith('_POSTING'); } get isBranchSelected(): boolean { return this.batchForm.get('branchScope')?.value === 'Selected'; } /** * Only a commission calculation has a breakdown worth showing: it is the one job where the figure * applied is not the figure worked out, because TDS and the standing deductions come off it. */ get showsCommissionBreakdown(): boolean { return this.jobType === 'COMMISSION_CALCULATION'; } /** What Generate found, in words — the same figure the Post button is about to apply. */ get previewTotal(): number { return this.previewRows.reduce((total, row) => total + Number(row.amount || 0), 0); } ngOnInit(): void { this.organizationService.getOffices().subscribe((offices: any[]) => { this.offices = offices || []; }); this.loadRecentRuns(); } money(value: any): string { if (value === null || value === undefined || value === '') { return '—'; } return DdsBatchProcessingComponent.MONEY.format(Number(value)); } labelOf(code: string): string { return this.jobTypes.find((type) => type.code === code)?.label ?? code; } toggleOffice(officeName: string): void { if (this.selectedOfficeNames.has(officeName)) { this.selectedOfficeNames.delete(officeName); } else { this.selectedOfficeNames.add(officeName); } } /** * What every past run did and whether it was applied. Without this a generated-but-unposted run is * invisible the moment the page is left, and there is no way to tell whether a month's interest * was ever actually posted. */ private loadRecentRuns(): void { this.organizationService.getDdsBatchRuns().subscribe((runs: any[]) => { this.recentRuns = runs || []; }); } generate(): void { const raw = this.batchForm.getRawValue(); const dateFormat = this.settingsService.dateFormat; const payload: any = { jobType: raw.jobType, branchScope: raw.branchScope, jobDate: this.dateUtils.formatDate(raw.jobDate, dateFormat), agentFrom: raw.agentFrom, agentTo: raw.agentTo, dateFormat, locale: this.settingsService.language.code }; // The party range narrows an interest job to particular accounts; commission is worked out per // agent on their whole collection, so sending a range there would only imply a filter that has // no effect. if (!this.isCommissionJob) { payload.partyFrom = raw.partyFrom; payload.partyTo = raw.partyTo; } if (raw.branchScope === 'Selected') { payload.officeNames = Array.from(this.selectedOfficeNames); } this.organizationService.generateDdsBatchRun(payload).subscribe((result: any) => { this.organizationService.getDdsBatchRun(result.resourceId).subscribe((batchRun: any) => { this.generatedRun = batchRun; this.previewRows = batchRun.rows || []; // A run that found nothing is the normal outcome of a period already processed, not a // failure — but it has to say so, or an empty table reads as the screen being broken. if (this.previewRows.length === 0) { this.alertService.alert({ type: 'Information', message: `${this.jobTypeLabel} found nothing to apply. Either this period has already been processed, or there was no activity in it.` }); } else { this.alertService.alert({ type: 'Success', message: `${this.jobTypeLabel}: ${this.previewRows.length} ${this.previewRows.length === 1 ? 'entry' : 'entries'} totalling ${this.money(this.previewTotal)} calculated. Nothing has been applied yet — review and press Post.` }); } this.loadRecentRuns(); }); }); } post(): void { if (!this.generatedRun || this.previewRows.length === 0) { return; } const count = this.previewRows.length; const total = this.money(this.previewTotal); const isPosting = this.isPostingJob; // Post is the step that moves money, and it cannot be undone from this screen — so it names // exactly what is about to change rather than asking a generic "are you sure". this.dialog .open(ConfirmationDialogComponent, { data: { type: 'warn', heading: `Post ${this.jobTypeLabel}?`, dialogContext: `${count} ${count === 1 ? 'entry' : 'entries'} totalling ${total} will be applied. ` + (isPosting ? 'This credits the customers or pays the agents, and appears on the ledger. ' : 'This records what has been earned against each account. ') + 'It cannot be undone from this screen.' } }) .afterClosed() .subscribe((response: any) => { if (response?.confirm) { this.applyRun(count, total); } }); } /** * Applies the run, and says what actually happened rather than assuming it finished. * * A crediting run costs what a savings deposit costs on every account it touches, so the server * takes it off the request and does it in the background - the reply comes back in a moment while * the work is still going on. Reporting that as "posted" would tell the clerk a run had finished * seconds after it began, and they would go looking for credits that were still being written. * * So the run is followed until it settles. A calculation run is done by the time the reply lands * and says so at once; a crediting run says it is under way, and the list beneath refreshes itself * until the server marks it posted - or failed, with the reason the server recorded. */ private applyRun(count: number, total: string): void { const label = this.jobTypeLabel; const runId = this.generatedRun.id; const crediting = this.isPostingJob; this.organizationService.postDdsBatchRun(runId).subscribe((response: any) => { this.generatedRun = null; this.previewRows = []; this.loadRecentRuns(); if (heldForApproval(response)) { announceHeldForApproval( response, `${label} (${count} ${count === 1 ? 'entry' : 'entries'}, ${total})`, this.organizationService, this.alertService ); return; } if (!crediting) { this.alertService.alert({ type: 'Success', message: `${label} posted — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}.` }); return; } this.postingRunId = runId; this.alertService.alert({ type: 'Posting', message: `${label} is running — ${count} ${count === 1 ? 'entry' : 'entries'}, ${total}. ` + 'It continues in the background; this list will update when it is done.' }); this.followRun(runId, label); }); } /** The crediting run being followed, if one is. The screen reads this to show it is under way. */ postingRunId: number | null = null; /** * Watches a background run until the server marks it settled. * * Polled rather than pushed: a batch that takes minutes needs no faster news than this, and a poll * costs nothing when the answer is one row. It gives up after a while rather than asking forever - * the run is still on the list either way, and a clerk can read its status there. */ private followRun(runId: number, label: string): void { let checksLeft = DdsBatchProcessingComponent.MAX_STATUS_CHECKS; const check = () => { this.organizationService.getDdsBatchRuns().subscribe({ next: (runs: any[]) => { const run = (runs || []).find((each: any) => each.id === runId); const status = run?.status; if (status === 'POSTED' || status === 'FAILED') { this.postingRunId = null; this.loadRecentRuns(); this.alertService.alert( status === 'POSTED' ? { type: 'Success', message: `${label} posted — ${run.rowsPosted ?? run.totalRows} entries applied.` } : { type: 'Error', message: `${label} failed: ${run.failureReason || 'see the run for details'}` } ); return; } if (--checksLeft > 0) { setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); } else { // Still going after the wait. Left on the list rather than reported either way - saying // "failed" for a run that is merely long would be worse than saying nothing. this.postingRunId = null; this.loadRecentRuns(); } }, // A failed status check says nothing about the run itself, which carries on regardless. error: () => { if (--checksLeft > 0) { setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); } else { this.postingRunId = null; } } }); }; setTimeout(check, DdsBatchProcessingComponent.STATUS_CHECK_INTERVAL_MS); } private static readonly STATUS_CHECK_INTERVAL_MS = 4000; /** Twenty minutes of checking, which is longer than any run measured here has taken. */ private static readonly MAX_STATUS_CHECKS = 300; /** Drops the preview without applying it — the safe way out of a calculation that looks wrong. */ discard(): void { this.generatedRun = null; this.previewRows = []; } exit(): void { this.router.navigate(['/organization']); } }