/** * 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, UntypedFormArray, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatIconModule } from '@angular/material/icon'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; /** Custom Components */ import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component'; import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component'; import { ApplyDdsReceiptDialogComponent, EntryDay, groupByDay } from './apply-dds-receipt-dialog/apply-dds-receipt-dialog.component'; /** 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'; /** * Daily Transaction Entry component. * * The screen a collection agent uses at the end of a day's round: pick a * transaction type, then batch-enter that day's collections against * assigned parties (Party Tran), settle collected cash to the branch * (Agent Tran), or record a withdrawal/transfer between accounts (Withdraw * Deposite). Modelled directly on the legacy Daily Transaction Detail * screen this replaces. * * Backed by the DDS Party Transaction API (`/dds/partytransactions`) for * Party Tran, Agent Tran and Withdraw Deposite - the three types this * screen actually collects fields for; Party Payment / OD Entry / OD * Payment remain placeholders until the backend distinguishes them too. * "Retrieve" (a ready-made list of today's assigned parties) isn't wired - * that needs a dedicated per-agent Party lookup this MVP doesn't have yet. */ @Component({ selector: 'mifosx-daily-transaction-entry', templateUrl: './daily-transaction-entry.component.html', styleUrls: ['./daily-transaction-entry.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, MatAutocompleteModule, MatIconModule ] }) export class DailyTransactionEntryComponent 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); private static readonly TRANSACTION_TYPE_CODES: Record = { 'Party Tran': 'PARTY_TRAN', 'Agent Tran': 'AGENT_TRAN', 'Withdraw Deposite': 'WITHDRAW_DEPOSITE' }; /** * The legacy screen's six types. Only the three the backend actually distinguishes can be picked - * the rest are shown but not selectable, so the screen stays honest about what the system does * today instead of accepting an entry it will reject on save. */ transactionTypes = [ { label: 'Party Tran', supported: true }, { label: 'Agent Tran', supported: true }, { label: 'Withdraw Deposite', supported: true }, { label: 'Party Payment', supported: false }, { label: 'OD Entry', supported: false }, { label: 'OD Payment', supported: false } ]; transactionModes = [ 'Cash', 'Transfer', 'Clearing' ]; entryForm: UntypedFormGroup = this.formBuilder.group({ agentId: [ '', Validators.required ], transactionType: [ 'Party Tran', Validators.required ], transactionDate: [ new Date(), Validators.required ], // Withdraw Deposite fields creditorPartyCode: [''], creditorPartyName: [ { value: '', disabled: true } ], creditorPartyBalance: [ { value: 0, disabled: true } ], debitPartyCode: [''], debitPartyName: [ { value: '', disabled: true } ], debitPartyBalance: [ { value: 0, disabled: true } ], withdrawAmount: [0], withdrawDescription: [''], // Agent Tran fields settlementAmount: [0], settlementMode: ['Cash'], settlementAccountType: [''], settlementAccountNo: [''], settlementDescription: [''], collections: this.formBuilder.array([]) }); agentInfo = { name: '', address: '', city: '', accountNo: '', guarantor1: '', guarantor2: '', currentBalance: 0 }; agentLookupFailed = false; /** Every agent still on the books - picked from a list rather than keyed by id. */ agents: any[] = []; /** * The picked agent's parties, fetched once when the agent is chosen. Every party code typed into * the grid is resolved from here, so a clerk keying a round of thirty collections waits on the * network once rather than thirty times - and a code belonging to another agent's party simply * will not resolve, which is the check that matters. */ agentParties: any[] = []; /** * The party behind the row being worked on, shown in the panel beside the agent's - who this * collection is for, what they owe daily, and what their balance becomes once it is saved. */ partyInfo: any = null; /** Which grid row the party panel is showing. */ activeRow = -1; /** * What has already been keyed for this agent on the chosen date. * * The real risk on this screen is not a wrong figure, it is the same collection book being keyed * twice - an interruption mid-round, and every customer in it gets credited again while the agent * appears to be holding cash he already handed over. Showing the round back removes the guesswork. */ todaysEntries: any[] = []; get todaysTotal(): number { return this.todaysEntries.reduce((sum, entry) => sum + Number(entry.amount || 0), 0); } /** * What this party has already been collected for on this date, if anything. * * Shown on the row itself rather than in a summary above it. A customer paying twice in a day is * legitimate - arrears, or two days at once - so this never blocks the entry. It only has to be * where the clerk is already looking at the moment they pick the party, which a panel at the top * of the screen is not. */ alreadyCollected(index: number): number { const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); if (!partyCode) { return 0; } return this.todaysEntries .filter((entry) => String(entry.partyCode) === partyCode) .reduce((sum, entry) => sum + Number(entry.amount || 0), 0); } /** * Where a party stands against the instalments it owes, in the words the branch uses. * * The running balance says how much has been paid, never whether it was paid on time - a customer * four days ahead and one eleven days behind can hold the same figure. This is what the clerk needs * at the moment they pick the party, so it is worked out per row rather than shown in a summary. * * A shortfall is split in two because the bank treats the halves differently: what is still inside * the catch-up window can be collected today, and what has fallen outside it is gone. Putting them * together would have the clerk asking for money the bank has already written off. */ schedule(party: any): { state: 'ahead' | 'behind' | 'level'; text: string } | null { const daily = Number(party?.dailyDepositAmount || 0); if (!party || daily <= 0) { return null; } const advance = Number(party.advanceAmount || 0); if (advance > 0) { return { state: 'ahead', text: `${this.money(advance)} already paid ahead` }; } // What to ask for today is the recoverable shortfall exactly as it stands - today's own // instalment is already inside it, because the schedule counts the first instalment on the // opening day itself. Adding the daily on top would charge today twice, which on an account // opened this morning reads as two days owed on its first day. // // Instalments past the catch-up window are deliberately left out of both the figure and the // words - they cannot be collected, so naming them would only have the clerk asking for money // the bank has already written off, and reading a shortfall the amount does not cover. const recoverable = Number(party.recoverableAmount || 0); if (recoverable > 0) { return { state: 'behind', text: `Take ${this.money(recoverable)} to complete today` }; } return { state: 'level', text: 'Up to date' }; } private money(value: number): string { return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } /** The party on a given collection row, so the row can show where that party stands. */ partyOfRow(index: number): any { const partyCode = String(this.collections.at(index)?.get('partyCode')?.value || ''); return partyCode ? this.agentParties.find((p) => String(p.partyCode) === partyCode) : null; } /** * Says so plainly when the agent just picked has nobody to collect from. * * An empty party list looks identical to a list that has not loaded, and a clerk who reads it as * the second will sit waiting for names that are never coming. Raised as a dialog rather than a * line under the field because it means this agent cannot be used for a round at all - there is * nothing to key, and the clerk should go back and pick another. */ private warnIfAgentHasNoParties(): void { if (this.agentParties.length > 0) { return; } const agentId = this.entryForm.get('agentId')?.value; const agent = this.agents.find((a: any) => String(a.id) === String(agentId)); this.dialog.open(NoticeDialogComponent, { data: { heading: 'No customers for this agent', // Named for what is actually missing. The agent's own settlement account is a different // thing and is usually there - saying "no accounts" sends whoever reads it to check the // wrong record. message: `No customer accounts are assigned to ${agent?.agentCode || 'this agent'}, so there is nothing ` + 'to collect. Assign customers to this agent first, or choose a different agent.' }, width: '420px', autoFocus: false }); } /** * Adds a row for days other than today - paid ahead, or owed from days gone by. * * Kept off the ordinary row and put behind its own button, because most collections are simply * today's instalment: a box asking about days would sit empty on nearly every row, and a stray * value left in one would charge a customer for days they did not pay for. * * The row it adds is filled in and left in the list rather than posted, so it is saved with the * rest of the round and the clerk sees it beside the others before committing. */ addDayBasedRow(mode: 'advance' | 'previous'): void { const dialogRef = this.dialog.open(DayCollectionDialogComponent, { data: { mode, parties: this.agentParties }, width: '460px', autoFocus: false }); dialogRef.afterClosed().subscribe((picked: any) => { if (!picked) { return; } this.addCollectionRow(); const row = this.collections.at(this.collections.length - 1); row.get('partyCode')?.setValue(picked.partyCode); this.loadPartyForRow(this.collections.length - 1); row.get('amount')?.setValue(picked.amount); row.get('advanceDays')?.setValue(picked.advanceDays ?? ''); row.get('arrearsDays')?.setValue(picked.arrearsDays ?? ''); row .get('description') ?.setValue( `${picked.days} ${picked.days === 1 ? 'day' : 'days'} ${mode === 'advance' ? 'in advance' : 'arrears'}` ); this.recalculateRowBalance(this.collections.length - 1); }); } /** * Questions an amount on an ordinary row that is not the day's instalment. * * Paying for other days now has its own buttons, and each works out the amount from the scheme * itself. What is left in the plain Amount box is one thing: today. Anything else is either a * mistyped figure - a zero too many turns a hundred rupees into a thousand - or days being keyed by * hand, which is the very thing the buttons exist to stop. * * It warns and does not block. A customer can hand over an odd amount, and the clerk is the one who * saw them do it; what they need is to be told before saving, because a collection cannot be taken * back afterwards - it can only be carried into the days that follow. */ overpayWarning(index: number): string | null { const party = this.partyOfRow(index); const daily = Number(party?.dailyDepositAmount || 0); const amount = Number(this.collections.at(index)?.get('amount')?.value || 0); if (!party || daily <= 0 || amount <= 0) { return null; } // Never raised on a row the Advance or Previous Days button filled in: those figures are the // scheme's own and the server checks them, so they cannot be the slip this is looking for. const row = this.collections.at(index); if (row?.get('advanceDays')?.value || row?.get('arrearsDays')?.value) { return null; } if (amount === daily) { return null; } if (amount > daily) { return ( `${this.money(amount)} is more than the daily ${this.money(daily)}. ` + 'Use Advance to pay ahead, or Previous Days to clear arrears.' ); } return `${this.money(amount)} is less than the daily ${this.money(daily)}.`; } /** * Whether this party is already paid ahead far enough that today is covered. * * The agent still calls - the round does not skip a door - but there is nothing to collect, so the * row is closed to entry rather than hidden. A party that vanished from the list would have the * clerk wondering what had happened to it. */ isCoveredByAdvance(party: any): boolean { return Number(party?.advanceDays || 0) > 0; } /** * The agent's own written-down collections for the chosen date, and whether they are being read. * * Held rather than typed. A Party Tran is no longer something the clerk composes - the agent * composed it on their round, and this screen shows it back so the cash can be counted against it. */ pendingEntries: any[] = []; pendingLoading = false; /** What the agent should be handing over: their own entries, added up. */ get pendingTotal(): number { return this.pendingEntries.reduce((sum: number, entry: any) => sum + Number(entry.amount || 0), 0); } /** * Reads everything this agent has outstanding up to the date on the form, customer by customer. * * Not only that day's: what was written on earlier days and not yet handed in is still owed, and a * clerk checking the round should see it in one list rather than open every date in turn. Each line * carries its own date. Asked again whenever the agent or the date changes. */ loadPendingEntries(): void { const agentId = this.entryForm.get('agentId')?.value; const date = this.entryForm.get('transactionDate')?.value; if (!agentId || !date) { this.pendingEntries = []; return; } this.pendingLoading = true; const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); this.organizationService.getAgentPendingCollections(agentId).subscribe({ next: (summary: any) => { this.pendingEntries = (summary?.entries || []).filter( (entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo ); this.pendingLoading = false; }, error: () => { this.pendingEntries = []; this.pendingLoading = false; } }); } /** The picked agent's own GL account code - where the cashier receipts their cash. */ agentGlCode: string | null = null; agentGlName: string | null = null; // ── Agent Tran: the agent's money, owed and received ────────────────────────────────────── // // The agent's cash reaches the bank at the cashier's counter, never on this screen. What Agent Tran // shows is where that stands: what the agent owes on everything they wrote down, what the cashier has // taken in against it, and what is left - and from here the receipts are applied to the customers. /** What the agent still has outstanding up to the date on the form, a day at a time. */ agentDays: EntryDay[] = []; /** DDS receipts on the agent's GL account not yet applied, including those still with the manager. */ agentReceipts: any[] = []; agentMoneyLoading = false; /** The day whose lines are open in the Agent Tran list. */ agentOpenDay: string | null = null; get agentOwed(): number { return this.agentDays.reduce((sum, day) => sum + day.total, 0); } get agentReceived(): number { return this.agentReceipts.filter((r) => r.status === 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); } get agentWaiting(): number { return this.agentReceipts.filter((r) => r.status !== 'APPROVED').reduce((sum, r) => sum + Number(r.amount || 0), 0); } /** * What is still to be taken from the agent for this date - never below nothing. The receipts are * not tied to a date (cash often reaches the counter the day after the round), so more received than * this date's entries is not a debt the other way; it is cash for another day, said so separately. */ get agentBalance(): number { return Math.max(0, Math.round((this.agentOwed - this.agentReceived) * 100) / 100); } /** Received cash beyond this date's entries - most likely for another day's. */ get agentReceivedBeyondDay(): number { return Math.max(0, Math.round((this.agentReceived - this.agentOwed) * 100) / 100); } loadAgentMoney(): void { const agentId = this.entryForm.get('agentId')?.value; const date = this.entryForm.get('transactionDate')?.value; this.agentOpenDay = null; if (!agentId || !date) { this.agentDays = []; this.agentReceipts = []; return; } this.agentMoneyLoading = true; // Everything still outstanding up to the date on the screen, whatever day it was written: an agent // who comes in after three days brings three days' cash, and the counter has to see all of it to // know what to take. const upTo = this.dateUtils.formatDate(date, 'yyyy-MM-dd'); this.organizationService.getAgentPendingCollections(agentId).subscribe({ next: (summary: any) => { this.agentDays = groupByDay( (summary?.entries || []).filter((entry: any) => entry.status === 'PENDING' && String(entry.entryDate) <= upTo) ); this.agentMoneyLoading = false; }, error: () => { this.agentDays = []; this.agentMoneyLoading = false; } }); this.organizationService.getAgentCashReceipts(agentId, undefined, true).subscribe({ next: (rows: any[]) => (this.agentReceipts = rows || []), error: () => (this.agentReceipts = []) }); } toggleAgentDay(date: string): void { this.agentOpenDay = this.agentOpenDay === date ? null : date; } /** * Opens Apply DDS Receipt: the cashier's DDS receipt on this agent's GL account, applied to what the * agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays * what it is - what to take from the agent - and the applying is done in one deliberate place. */ openApplyReceipt(): void { const agentId = this.entryForm.get('agentId')?.value; if (!agentId) { return; } const picked = this.agents.find((agent: any) => agent.id === agentId); this.dialog .open(ApplyDdsReceiptDialogComponent, { width: '780px', maxWidth: '96vw', panelClass: 'apply-collection-dialog-panel', data: { agentId, agentCode: picked?.agentCode || '', agentName: this.agentInfo.name || picked?.agentNameId || '', glCode: this.agentGlCode, // The day chosen on the screen: its entries are what the receipts settle. entryDate: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd') } }) .afterClosed() .subscribe((result: any) => { if (!result) { return; } const money = Number(result.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); const vouchers: string[] = result.vouchers || []; if (heldForApproval(result.response)) { announceHeldForApproval(result.response, `Applying ${money}`, this.organizationService, this.alertService); this.loadPendingEntries(); return; } this.alertService.alert({ type: 'Success', message: `${vouchers.length === 1 ? 'Voucher' : 'Vouchers'} ${vouchers.join(', ')} applied - ` + `${result.entries} ${result.entries === 1 ? 'entry' : 'entries'}, ${money}` }); this.loadPendingEntries(); this.loadTodaysEntries(); this.loadAgentMoney(); }); } get isPartyTran(): boolean { return this.entryForm.get('transactionType').value === 'Party Tran'; } get isWithdrawDeposite(): boolean { return this.entryForm.get('transactionType').value === 'Withdraw Deposite'; } get isAgentTran(): boolean { return this.entryForm.get('transactionType').value === 'Agent Tran'; } get collections(): UntypedFormArray { return this.entryForm.get('collections') as UntypedFormArray; } private buildCollectionRow(): UntypedFormGroup { const row = this.formBuilder.group({ partyCode: [ '', Validators.required ], transactionMode: [ 'Cash', Validators.required ], accountType: [''], accountNo: [ '', Validators.required ], amount: [ 0, Validators.required ], /* * Days taken in advance, when the customer is paying ahead rather than for today. Empty on an * ordinary row. The amount follows from it - the scheme's daily instalment times the days - so * the clerk picks days, not rupees, and cannot quote a figure the scheme does not charge. */ advanceDays: [''], /* Days of arrears being cleared. Same idea as advanceDays, the other way round. */ arrearsDays: [''], chequeNo: [''], tokenNo: [''], description: [''], partyName: [ { value: '', disabled: true } ], balanceAfterTransaction: [ { value: 0, disabled: true } ] }); // Cash has no instrument behind it, so switching back to cash drops anything typed against a // cheque or clearing entry rather than saving a stale number with it. row.get('transactionMode')?.valueChanges.subscribe((mode) => { if (mode === 'Cash') { row.get('chequeNo')?.setValue('', { emitEvent: false }); row.get('tokenNo')?.setValue('', { emitEvent: false }); } }); return row; } addCollectionRow(): void { this.collections.push(this.buildCollectionRow()); } removeCollectionRow(index: number): void { this.collections.removeAt(index); } ngOnInit(): void { // Closed agents are off the books entirely; everyone else is kept, and which of them can be // picked depends on the transaction type - see selectableAgents. this.organizationService.getAgents().subscribe({ next: (agents: any[]) => (this.agents = (agents || []).filter((agent: any) => !agent.closeDate)), error: () => (this.agents = []) }); // Withdraw Deposite: the open accounts either side can be picked from, and a code typed or picked // fills in whose account it is and what it holds, so the clerk checks the transfer against both. this.organizationService.getParties().subscribe({ next: (parties: any) => { const all = Array.isArray(parties) ? parties : parties?.pageItems || []; this.openParties = all.filter((party: any) => party.status === 'ACTIVE'); this.fillTransferParty('creditor'); this.fillTransferParty('debit'); }, error: () => (this.openParties = []) }); this.entryForm.get('creditorPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('creditor')); this.entryForm.get('debitPartyCode')?.valueChanges.subscribe(() => this.fillTransferParty('debit')); } /** Open DDS accounts, for the two sides of a Withdraw Deposite. */ openParties: any[] = []; /** * The open accounts either side can be picked from. The server moves money only between two * accounts of the agent on the entry, so once an agent is chosen only that agent's accounts are offered. */ private get transferableParties(): any[] { const agentId = Number(this.entryForm.get('agentId')?.value) || null; return agentId ? this.openParties.filter((party: any) => Number(party.agentId) === agentId) : this.openParties; } /** The open accounts matching what has been typed on one side - by code, name or account number, any case. */ transferPartyOptions(side: 'creditor' | 'debit'): any[] { const typed = String(this.entryForm.get(side + 'PartyCode')?.value || '') .trim() .toLowerCase(); const other = String(this.entryForm.get(this.otherSide(side) + 'PartyCode')?.value || '') .trim() .toUpperCase(); // Once an account is picked the whole list comes back, so another can be chosen without clearing the box. const picked = this.transferableParties.some((party: any) => String(party.partyCode).toLowerCase() === typed); return this.transferableParties .filter((party: any) => String(party.partyCode).toUpperCase() !== other) .filter( (party: any) => !typed || picked || String(party.partyCode).toLowerCase().includes(typed) || String(party.partyName || '') .toLowerCase() .includes(typed) || String(party.accountNo || '') .toLowerCase() .includes(typed) ) .slice(0, 50); } /** True when the same account has been put on both sides. */ get transferToItself(): boolean { const creditor = String(this.entryForm.get('creditorPartyCode')?.value || '') .trim() .toUpperCase(); const debit = String(this.entryForm.get('debitPartyCode')?.value || '') .trim() .toUpperCase(); return this.isWithdrawDeposite && !!creditor && creditor === debit; } /** * When the box is left with a name or part of one typed in and only one account matches it, that * account is taken - so typing "amit" and moving on picks Amit's account without opening the list. */ settleTransferParty(side: 'creditor' | 'debit'): void { const control = this.entryForm.get(side + 'PartyCode'); const typed = String(control?.value || '').trim(); if (!typed || this.partyByCode(typed)) { return; } const matches = this.transferPartyOptions(side); if (matches.length === 1) { control?.setValue(matches[0].partyCode); } } private otherSide(side: 'creditor' | 'debit'): 'creditor' | 'debit' { return side === 'creditor' ? 'debit' : 'creditor'; } private partyByCode(code: string): any { const wanted = String(code || '') .trim() .toUpperCase(); return wanted ? this.openParties.find((each: any) => String(each.partyCode).toUpperCase() === wanted) : undefined; } /** * Fills a side's name and balance from the code on it, and clears them when it matches no account. * The code is matched in any case and put back as the account has it, so "pt0002" becomes PT0002. */ private fillTransferParty(side: 'creditor' | 'debit'): void { const control = this.entryForm.get(side + 'PartyCode'); const party = this.partyByCode(control?.value); if (party && control?.value !== party.partyCode) { control?.setValue(party.partyCode, { emitEvent: false }); } this.entryForm.get(side + 'PartyName')?.setValue(party ? party.partyName : '', { emitEvent: false }); this.entryForm .get(side + 'PartyBalance') ?.setValue(party ? Number(party.currentBalance || 0) : 0, { emitEvent: false }); } /** * The agents that can be picked for the transaction type currently chosen. * * A deactivated agent no longer collects, so they are not offered for a collection or a transfer. * Settlement is the exception: an agent switched off mid-round is still holding the cash they * already took, and that cash has to be able to reach a branch. Leaving them out of the settlement * list would strand it with no way to hand it in. */ get selectableAgents(): any[] { if (this.isAgentTran) { return this.agents; } return this.agents.filter((agent: any) => agent.active); } /** Flags a picked agent that is no longer collecting, so the entry is not made by mistake. */ get pickedAgentIsInactive(): boolean { const agentId = this.entryForm.get('agentId')?.value; if (!agentId) { return false; } const picked = this.agents.find((agent: any) => agent.id === agentId); return !!picked && !picked.active; } /** Whether this row needs a cheque or token number - a cash collection has neither. */ needsInstrument(index: number): boolean { return this.collections.at(index)?.get('transactionMode')?.value !== 'Cash'; } loadAgent(): void { const agentId = this.entryForm.get('agentId')?.value; this.agentLookupFailed = false; this.partyInfo = null; this.activeRow = -1; if (!agentId) { this.agentParties = []; this.pendingEntries = []; this.agentGlCode = null; this.agentDays = []; this.agentReceipts = []; return; } this.organizationService.getAgent(agentId).subscribe({ next: (agent: any) => { const guarantors = agent.guarantors || []; this.agentInfo = { name: agent.agentNameId || '', address: agent.agentAddress1 || '', city: agent.agentCity || '', accountNo: agent.agentSavingAccountNo || '', guarantor1: guarantors[0]?.guarantorName || '', guarantor2: guarantors[1]?.guarantorName || '', currentBalance: agent.agentCurrentBalance || 0 }; this.agentGlCode = agent.glAccountCode || null; this.agentGlName = agent.glAccountName || null; }, error: () => { this.agentLookupFailed = true; this.agentInfo = { name: '', address: '', city: '', accountNo: '', guarantor1: '', guarantor2: '', currentBalance: 0 }; } }); this.organizationService.getPartiesOfAgent(agentId).subscribe({ next: (parties: any[]) => { this.agentParties = parties || []; this.warnIfAgentHasNoParties(); }, error: () => (this.agentParties = []) }); this.loadTodaysEntries(); this.loadPendingEntries(); this.loadAgentMoney(); } /** This agent's collections already recorded for the date on the screen. */ loadTodaysEntries(): void { const agentId = this.entryForm.get('agentId')?.value; if (!agentId) { this.todaysEntries = []; return; } const onDate = this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'yyyy-MM-dd'); this.organizationService.getPartyTransactionsOfAgent(agentId).subscribe({ next: (transactions: any[]) => { this.todaysEntries = (transactions || []).filter( (transaction: any) => transaction.transactionType === 'PARTY_TRAN' && this.dateUtils.formatDate(transaction.transactionDate, 'yyyy-MM-dd') === onDate ); }, error: () => (this.todaysEntries = []) }); } /** * Resolves the party code typed into a row. A code this agent does not collect for is left * unresolved rather than fetched anyway - an agent posting against someone else's party is the * mistake this screen most needs to catch. */ loadPartyForRow(index: number): void { const row = this.collections.at(index); if (!row) { return; } const partyCode = String(row.get('partyCode')?.value || '').trim(); this.activeRow = index; const party = this.agentParties.find((candidate) => String(candidate.partyCode) === partyCode); this.partyInfo = party || null; row.get('partyName')?.setValue(party?.partyName || '', { emitEvent: false }); // The account credited is the party's own linked savings account, never keyed by hand. row.get('accountNo')?.setValue(party?.creditToAccount || party?.partyCode || '', { emitEvent: false }); row.get('accountType')?.setValue(party?.creditToAccountType || 'Savings', { emitEvent: false }); this.recalculateRowBalance(index); } /** The party's balance once this row is saved - what the legacy screen called Bal. After This Tr. */ recalculateRowBalance(index: number): void { const row = this.collections.at(index); if (!row) { return; } const party = this.agentParties.find( (candidate) => String(candidate.partyCode) === String(row.get('partyCode')?.value || '').trim() ); const current = Number(party?.currentBalance || 0); const amount = Number(row.get('amount')?.value || 0); row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false }); } /** The balance the party panel shows - live against whatever the active row currently holds. */ get partyBalanceAfter(): number { if (!this.partyInfo || this.activeRow < 0) { return 0; } const row = this.collections.at(this.activeRow); return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0); } /** Total of the round being keyed in - what the agent should be handing over. */ get collectionTotal(): number { return this.collections.controls.reduce((sum, row) => sum + Number(row.get('amount')?.value || 0), 0); } /** Re-reads what has already been keyed for this agent on the chosen date. */ retrieve(): void { this.loadTodaysEntries(); } viewLedger(): void { // No ledger view exists yet for this agent. } signature(): void { // No signature capture/verification flow exists yet. } cancel(): void { this.entryForm.reset({ transactionType: 'Party Tran', transactionDate: new Date() }); this.collections.clear(); } exit(): void { this.router.navigate(['/accounts/dds']); } /** * Save, asking first when what is about to happen is a settlement. * * A settlement credits every customer on the list and records the cash as the agent's, in one * act that cannot be taken back - so the screen says the figure out loud and waits. The question * is the one the clerk is actually answering at the counter: is this much really in front of me. * * The double-entry guard this used to carry has moved to where the entries are now made. Two * lines for one account on one day are refused on the agent's own screen, and refused again by * the server when the day is settled; by the time a clerk sees the list there is nothing left to * warn about. */ save(): void { // A Party Tran cannot be saved from here at all. The screen shows the agent's own entries so // the cash can be counted against them; taking that cash in is done where the branch takes cash // in, and two ways to credit the same customers is one way too many. The button is not rendered // for this type either - this is the same rule said again where it cannot be bypassed. // Agent Tran is not saved from here either any more. The agent's cash is taken in at the cashier's // counter, and a second way to take the same cash in is one way too many; Agent Tran shows where it // stands and applies the cashier's receipts. if (this.isPartyTran || this.isAgentTran) { return; } this.postTransaction(); } private postTransaction(): void { const raw = this.entryForm.getRawValue(); // A Party Tran is no longer a transaction this screen composes. The agent wrote the round down // on their own; what happens here is that it is settled, all of it at once, through a call of // its own. Everything else on this screen still posts a transaction the way it always did. if (this.isPartyTran) { this.settleCollections(); return; } const dateFormat = this.settingsService.dateFormat; const locale = this.settingsService.language.code; const transactionType = DailyTransactionEntryComponent.TRANSACTION_TYPE_CODES[raw.transactionType]; const payload: any = { agentId: Number(raw.agentId), transactionType, transactionDate: this.dateUtils.formatDate(raw.transactionDate, dateFormat), dateFormat, locale }; if (this.isWithdrawDeposite) { payload.creditorPartyCode = raw.creditorPartyCode; payload.debitPartyCode = raw.debitPartyCode; payload.withdrawAmount = raw.withdrawAmount; payload.withdrawDescription = raw.withdrawDescription || undefined; } else if (this.isAgentTran) { payload.amount = raw.settlementAmount; payload.transactionMode = raw.settlementMode; payload.accountType = raw.settlementAccountType || undefined; payload.accountNo = raw.settlementAccountNo || undefined; payload.description = raw.settlementDescription || undefined; } // What was just posted, captured before the form is cleared, so the confirmation can name it. const posted = this.postedSummary(raw); this.organizationService.createPartyTransaction(payload).subscribe((response: any) => { // On a money screen a silent save is worse than no save: with nothing to confirm it, a clerk // who looks away reasonably assumes it did not go through and posts the whole round again. // // With maker-checker on, the request comes back with a command id and nothing done: it is // waiting for a checker, and saying it was posted would be telling the clerk the money moved. const awaitingApproval = !!response?.commandId && response?.resourceId == null; this.alertService.alert({ type: 'Success', message: awaitingApproval ? `Sent for approval: ${posted}. Nothing moves until a checker approves it.` : posted }); this.collections.clear(); this.partyInfo = null; this.activeRow = -1; // The round just saved becomes part of what the screen shows back. this.loadTodaysEntries(); this.entryForm.patchValue({ creditorPartyCode: '', debitPartyCode: '', withdrawAmount: 0, withdrawDescription: '', settlementAmount: 0, settlementAccountType: '', settlementAccountNo: '', settlementDescription: '' }); this.loadAgent(); }); } /** * Hands the agent's day over: every entry they wrote down for this date becomes a real credit. * * No amount is sent and none is asked for on screen. What settles is what the entries say - a * figure typed at the counter would be a second opinion about a total the entries already give, * and the two could differ. The clerk's part is to count the cash against that total and agree. * * All or none. A part-settled round would leave the branch holding cash against a list that no * longer matched it, which is a worse position than simply running it again. */ private settleCollections(): void { const raw = this.entryForm.getRawValue(); if (this.pendingEntries.length === 0) { return; } const count = this.pendingEntries.length; const total = this.pendingTotal; const agent = this.agentInfo.name || 'agent'; this.organizationService .settleAgentCollections({ agentId: Number(raw.agentId), entryDate: this.dateUtils.formatDate(raw.transactionDate, 'yyyy-MM-dd'), dateFormat: 'yyyy-MM-dd', locale: this.settingsService.language.code }) .subscribe((response: any) => { const money = Number(total).toLocaleString('en-IN', { minimumFractionDigits: 2 }); if (heldForApproval(response)) { announceHeldForApproval( response, `${count} ${count === 1 ? 'collection' : 'collections'} for ${agent} (${money})`, this.organizationService, this.alertService ); this.loadPendingEntries(); return; } this.alertService.alert({ type: 'Success', message: count + (count === 1 ? ' collection' : ' collections') + ' posted for ' + agent + ' - ' + money }); // The list empties because those entries are settled now, and the agent's cash goes up by // what was just taken - both are read again rather than worked out on screen. this.loadPendingEntries(); this.loadTodaysEntries(); this.loadAgent(); }); } /** * Says what was actually posted, in the terms the clerk was working in - a count and a total for a * round of collections, the amount and who it came from for anything else. "Saved successfully" * would confirm that something happened without confirming that the right thing happened. */ private postedSummary(raw: any): string { const money = (value: any) => Number(value || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 }); const agent = this.agentInfo.name || 'agent'; if (this.isPartyTran) { const rows = raw.collections || []; const total = rows.reduce((sum: number, row: any) => sum + Number(row.amount || 0), 0); return `${rows.length} ${rows.length === 1 ? 'collection' : 'collections'} posted for ${agent} — ${money(total)}`; } if (this.isAgentTran) { return `${agent} settled ${money(raw.settlementAmount)} to the branch`; } return `${money(raw.withdrawAmount)} transferred from ${raw.debitPartyCode} to ${raw.creditorPartyCode}`; } }