Files
cbsnexora/src/app/deposits/daily-transaction-entry/daily-transaction-entry.component.ts
T

1101 lines
42 KiB
TypeScript

/**
* 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<string, string> = {
'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}`;
}
}