1101 lines
42 KiB
TypeScript
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}`;
|
|
}
|
|
}
|