Files
cbsnexora/src/app/cashier/branch-day/branch-day.component.ts
T
niteshandClaude Sonnet 5 7b77166b19 fix: tenant-config fixes, cashier/share/savings UI bugs, and menu-access hardcoding
- Add Balance Sheet report and disabled-features accounting config
- Point menu-access tenant header and env.js/environment.ts tenant identifier
  at env config instead of hardcoded 'default'/'lincpay' literals
- Surface the real backend error (not a generic "no reference id") when an
  OTP send fails in contact-otp-block
- Fix branch-day "₹null" display bug (null vs undefined) and add a guard
  blocking a branch's first-ever day open before cash denomination migration
- Dynamically size the denomination count input and block re-opening an
  already-open business day from Migrate Cash Denomination
- Lock the denomination-managed cash GL row in Migrate Opening Balances
  (read-only, with a link back to Migrate Cash Denomination)
- Remove duplicate Cancel/Submit action bars on the Share Product and
  Savings Account creation preview steps; align Previous into the same row
- Hide "Apply Collection" on a share account once its advance collection
  is already settled (further purchases go through Apply Additional Shares)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:19:21 +05:30

718 lines
26 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CashierService } from '../cashier.service';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
interface BranchDay {
id: number;
officeId: number;
officeName: string | null;
businessDate: string;
openedByName: string | null;
openedAt: string | null;
openingCash: number | null;
closedByName: string | null;
closedAt: string | null;
closingCash: number | null;
bookCash: number | null;
cashDifference: number | null;
forceClosed: boolean;
forceCloseReason: string | null;
remarks: string | null;
status: 'OPEN' | 'CLOSED';
/** Only present on the missed-close list. */
daysOpen?: number;
}
/**
* Branch day register — opening and closing the branch's business day, and the list of days that
* were opened and never closed.
*
* A day left open is not a tidiness problem: its closing balance never carries forward, a
* back-dated entry can still land in it, and any cash difference on that date goes unnoticed.
*/
@Component({
selector: 'mifosx-branch-day',
templateUrl: './branch-day.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./branch-day.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
RouterLink
]
})
export class BranchDayComponent implements OnInit {
private service = inject(CashierService);
private route = inject(ActivatedRoute);
/**
* Set from the route: the missed-close register is the same data as the day register, shown with
* only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed
* Close Register" looked like nothing had happened whenever the branch was up to date.
*/
missedOnly = false;
days: BranchDay[] = [];
missed: BranchDay[] = [];
openDay: any = null;
today = '';
/** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */
canOperate = false;
loading = false;
loaded = false;
errorMsg = '';
successMsg = '';
/** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at
* Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash
* tracking begin while the ledger silently stays wherever it already was. The backend enforces
* this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen);
* this flag only steers the user to the right screen before they hit that rejection. */
firstOpenNeedsCashMigration = false;
// Open-day form
showOpenForm = false;
openDate = '';
openingCash: number | null = null;
/** Required only when the counted/typed opening figure differs from yesterday's own closing
* figure — the backend rejects the open without it in that case. */
openDifferenceReason = '';
opening = false;
// Close-day form. The date is fixed by which day is being closed, never typed.
closeTarget: BranchDay | null = null;
closingCash: number | null = null;
closeRemarks = '';
forceReason = '';
closing = false;
// Denomination breakdown, shared by both the open and close forms — a physical note/coin count
// is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form
// since opening and closing are two independent physical counts.
notes: Denom[] = [];
coins: Denom[] = [];
openCounts: Record<number, number> = {};
closeCounts: Record<number, number> = {};
/** What yesterday's own close counted, note by note — the open form starts from this instead of
* zero. The backend already carries this figure forward on its own when nothing is submitted for
* the day being opened; showing it here is only about letting whoever opens the day see and
* correct it, not a second place the carry-forward logic lives. */
openCountsAuto: Record<number, number> = {};
/** What the vault + every cashier's live position added up to when the close form opened — the
* physical-count table starts from this instead of zero, since the EOD summary already fetched
* it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */
closeCountsAuto: Record<number, number> = {};
get allDenoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
this.missedOnly = !!this.route.snapshot.data['missedOnly'];
this.load();
this.service.getActiveDenominations().subscribe({
next: (list) => {
const rows: Denom[] = (list || []).map((x: any) => ({
id: x.id,
value: x.denomination,
kind: x.kind,
label: '₹' + x.denomination
}));
this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value);
this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
}
countFor(counts: Record<number, number>, d: Denom): number {
return counts[d.id] || 0;
}
subtotalFor(counts: Record<number, number>, d: Denom): number {
return this.countFor(counts, d) * d.value;
}
onCountChange(counts: Record<number, number>, d: Denom, raw: any): void {
counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpCount(counts: Record<number, number>, d: Denom, delta: number): void {
counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta);
}
totalOf(counts: Record<number, number>): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(counts, d), 0);
}
/** Denominations with a positive count, shaped for the API payload. */
private denominationPayload(counts: Record<number, number>) {
return this.allDenoms
.filter((d) => this.countFor(counts, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(counts, d) }));
}
get openDenomTotal(): number {
return this.totalOf(this.openCounts);
}
get closeDenomTotal(): number {
return this.totalOf(this.closeCounts);
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.branchDayRegister()
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.days = res?.days ?? [];
this.openDay = res?.openDay ?? null;
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
});
this.service
.branchDayMissed()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.missed = res?.missed ?? [];
});
}
/**
* Only relevant the moment this branch has never opened a business day before — every later
* open/close on this screen is untouched, so there is nothing to re-check once history exists.
*/
private checkFirstOpenCashMigration(): void {
if (this.days.length > 0 || this.openDay) {
this.firstOpenNeedsCashMigration = false;
return;
}
this.service.cashGlStatus().subscribe({
next: (status: any) => {
// configured === false means this tenant has no Cash at Vault / Cash at Teller account
// mapped at all — nothing for this check to reconcile against, so it does not block.
this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance;
},
error: () => {
this.firstOpenNeedsCashMigration = false;
}
});
}
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration;
}
/**
* A still-open day's closing/book figures are dropped from the API response entirely (the
* backend's Gson instance omits a null map value rather than sending it as JSON null), so the
* field arrives as undefined, not null. Checking only `!== null` against that — which a stricter
* pass of this file made the mistake of doing — leaves undefined on the "has a value" branch,
* which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the
* literal text "₹null". This checks for either, the way an open day actually shows up.
*/
hasValue(value: number | null | undefined): boolean {
return value !== null && value !== undefined;
}
startOpen(): void {
this.showOpenForm = true;
this.openDate = this.today;
// Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure
// over it is how a carried balance drifts.
this.openingCash = null;
this.openCounts = {};
this.openCountsAuto = {};
this.openDifferenceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.loadCarriedForwardDenominations();
}
cancelOpen(): void {
this.showOpenForm = false;
this.openCountsAuto = {};
}
/**
* Pulls yesterday's own closing note/coin count so the open form shows what is about to be
* carried forward instead of a blank table the caller has to trust blindly. Only the most
* recently closed day is used — the same day the backend itself falls back to when the caller
* submits no denominations and no lump sum.
*/
private loadCarriedForwardDenominations(): void {
const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null);
if (!lastClosed) return;
this.service
.branchDayDenominations(lastClosed.officeId, lastClosed.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const closing = res?.closing || [];
if (!closing.length) return;
const totals: Record<number, number> = {};
for (const d of closing) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
this.openCountsAuto = totals;
// Only overwrite the table if nothing has been typed/counted since the form opened.
if (this.openDenomTotal === 0 && this.openingCash == null) {
this.openCounts = { ...totals };
}
});
}
get hasAutoFilledOpenCounts(): boolean {
return Object.keys(this.openCountsAuto).length > 0;
}
autoOpenCountFor(d: Denom): number {
return this.openCountsAuto[d.id] || 0;
}
resetOpenToAutoFill(): void {
this.openCounts = { ...this.openCountsAuto };
}
get carriedForwardTotal(): number {
return this.totalOf(this.openCountsAuto);
}
/** The figure the form would actually submit right now — the denomination total when any note/coin
* has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */
get effectiveOpeningFigure(): number | null {
return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash;
}
/** Difference between what is about to be opened with and what yesterday actually closed with —
* visible before saving, the same way the close form shows a live difference against the book
* figure, so a short/excess opening count isn't discovered only after the day is already running. */
get openLiveDifference(): number | null {
if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null;
return this.effectiveOpeningFigure - this.carriedForwardTotal;
}
/** True once a reason is needed but hasn't been typed — the same "explain the gap before it's
* allowed" gate the close form already applies to a late close. */
get needsOpenDifferenceReason(): boolean {
return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim();
}
get canSubmitOpen(): boolean {
return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason;
}
submitOpen(): void {
if (!this.openDate || this.needsOpenDifferenceReason) return;
this.opening = true;
this.errorMsg = '';
const denominations = this.denominationPayload(this.openCounts);
this.service
.openBranchDay(
this.openDate,
this.openingCash ?? undefined,
undefined,
denominations,
this.openDifferenceReason.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.opening = false;
if (res) {
this.showOpenForm = false;
this.successMsg = `Business day ${res.businessDate} opened.`;
this.load();
}
});
}
/** The day's expected figure and the working behind it, fetched when the close form opens. */
preview: any = null;
previewLoading = false;
startClose(day: BranchDay): void {
this.closeTarget = day;
this.closingCash = null;
this.closeCounts = {};
this.closeCountsAuto = {};
this.closeRemarks = '';
this.forceReason = '';
this.successMsg = '';
this.errorMsg = '';
this.preview = null;
// Pre-fill the counted figure with what the books expect. It stays editable — the day is still
// closed on what was physically counted — but in the ordinary case where the drawer tallies
// there is nothing to type, and no chance of a slip of the keyboard inventing a difference.
this.previewLoading = true;
this.service
.branchDayClosePreview(day.businessDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.previewLoading = false;
this.preview = res;
if (res?.expectedCash != null && this.closingCash == null) {
this.closingCash = Number(res.expectedCash);
}
});
this.loadEodSummary(day);
}
// ── EOD denomination summary: what's in the vault, and what every cashier under it is
// currently holding, fetched automatically so closing the day doesn't require separately
// checking Vault Position and every cashier's own Cashier Position screen by hand. ──
eodLoading = false;
eodTellers: any[] = [];
/**
* Vault + every cashier's current holding, added up across the whole branch and checked against
* the book figure — the question a manager actually needs answered at close: does the BRANCH
* tally, not just the vault alone. Computed on read rather than stored, so it never has to be
* kept in sync across the two independent loads (the book preview and the EOD summary) that feed
* it.
*/
get branchTotalSummary(): {
vaultTotal: number;
cashierTotal: number;
grandTotal: number;
expected: number;
difference: number;
} | null {
if (!this.preview || this.eodTellers.length === 0) return null;
let vaultTotal = 0;
let cashierTotal = 0;
for (const t of this.eodTellers) {
vaultTotal += Number(t.vault?.totalAmount ?? 0);
for (const c of t.cashiers) {
cashierTotal += Number(c.currentTotal ?? 0);
}
}
const grandTotal = vaultTotal + cashierTotal;
const expected = Number(this.preview.expectedCash ?? 0);
return { vaultTotal, cashierTotal, grandTotal, expected, difference: grandTotal - expected };
}
private loadEodSummary(day: BranchDay): void {
this.eodLoading = true;
this.eodTellers = [];
this.service
.getTellers(day.officeId)
.pipe(
switchMap((tellers) => {
const list = tellers || [];
if (!list.length) return of([]);
return forkJoin(list.map((t: any) => this.loadTellerSummary(t, day.businessDate)));
}),
catchError(() => of([]))
)
.subscribe((results: any[]) => {
this.eodLoading = false;
this.eodTellers = results;
this.autoFillCloseCounts();
});
}
/**
* Seeds the physical-count table from what the vault and every cashier are already shown to be
* holding, instead of leaving it at zero and making whoever closes the day re-key numbers that
* are already on screen above. It stays a starting point, not a lock — any row can still be
* corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction.
*/
private autoFillCloseCounts(): void {
const totals: Record<number, number> = {};
for (const t of this.eodTellers) {
for (const d of t.vault?.denominations || []) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
for (const c of t.cashiers || []) {
for (const d of c.denominations || []) {
totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0);
}
}
}
if (Object.keys(totals).length === 0) return;
this.closeCountsAuto = totals;
this.closeCounts = { ...totals };
}
get hasAutoFilledCounts(): boolean {
return Object.keys(this.closeCountsAuto).length > 0;
}
/** The auto-fetched count for one denomination, so a row that was hand-edited away from it can
* still show what the vault + cashier positions said. */
autoCountFor(d: Denom): number {
return this.closeCountsAuto[d.id] || 0;
}
resetToAutoFill(): void {
this.closeCounts = { ...this.closeCountsAuto };
}
private loadTellerSummary(teller: any, date: string) {
return forkJoin({
vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))),
cashiers: this.service.getCashiersForTeller(teller.id).pipe(catchError(() => of({ cashiers: [] })))
}).pipe(
switchMap(({ vault, cashiers }) => {
const cashierList = cashiers?.cashiers || [];
if (!cashierList.length) {
return of({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: [] });
}
return forkJoin(cashierList.map((c: any) => this.loadCashierSummary(c, date))).pipe(
map((cashierSummaries: any[]) => ({
tellerId: teller.id,
tellerName: teller.name,
vault,
cashiers: cashierSummaries
}))
);
})
);
}
private loadCashierSummary(cashier: any, date: string) {
return forkJoin({
position: this.service.cashierPositionReport(cashier.id, date).pipe(catchError(() => of(null))),
morning: this.service.getCashierMorningBalance(cashier.id, date).pipe(catchError(() => of(null))),
reconciliation: this.service.checkReconciliation(cashier.id, date).pipe(catchError(() => of(null))),
settlement: this.service.checkSettlement(cashier.id, date).pipe(catchError(() => of(null))),
glEntries: this.service.getGlEntries(date, cashier.id, undefined, 0, 1).pipe(catchError(() => of(null)))
}).pipe(
map(({ position, morning, reconciliation, settlement, glEntries }: any) => {
const morningMap = new Map<number, number>();
(morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0));
return {
cashierId: cashier.id,
cashierName: cashier.staffName,
morningTotal: Number(morning?.totalMorning ?? 0),
currentTotal: Number(position?.totalAmount ?? 0),
reconciled: reconciliation?.reconciled ?? null,
alreadySettled: settlement?.alreadySettled ?? null,
txnCount: glEntries?.totalFilteredRecords ?? null,
denominations: ((position?.denominations || []) as any[]).map((d: any) => ({
...d,
morningCount: morningMap.get(d.denominationId) ?? 0,
delta: d.count - (morningMap.get(d.denominationId) ?? 0)
}))
};
})
);
}
/** The counted figure the form would submit right now — the denomination total when any note/coin
* has been counted, otherwise the plain typed-in amount. */
get effectiveClosingFigure(): number | null {
return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash;
}
/** Difference the form would record right now, so it is visible before saving. */
get liveDifference(): number | null {
if (!this.preview || this.effectiveClosingFigure == null) return null;
return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0);
}
/** Puts the expected figure back after an edit. */
useExpected(): void {
if (this.preview?.expectedCash != null) {
this.closingCash = Number(this.preview.expectedCash);
}
}
cancelClose(): void {
this.closeTarget = null;
this.eodTellers = [];
this.closeCountsAuto = {};
}
/** A date earlier than today is a late close, and has to say why it was left open. */
get isLateClose(): boolean {
return !!this.closeTarget && this.closeTarget.businessDate < this.today;
}
/**
* True when any cashier who was given cash (currentTotal > 0) is still NOT reconciled
* or NOT settled — meaning outstanding cash hasn't been accounted for yet.
*/
get hasUnreconciledOrUnsettledCashiers(): boolean {
for (const t of this.eodTellers) {
for (const c of t.cashiers) {
if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
return true;
}
}
}
return false;
}
/** Names of cashiers blocking the close, for the error message. */
get unreconciledOrUnsettledCashierDetails(): { name: string; notReconciled: boolean; notSettled: boolean }[] {
const result: { name: string; notReconciled: boolean; notSettled: boolean }[] = [];
for (const t of this.eodTellers) {
for (const c of t.cashiers) {
if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) {
result.push({
name: c.cashierName,
notReconciled: c.reconciled === false,
notSettled: c.alreadySettled === false
});
}
}
}
return result;
}
/**
* Whether a reason has to be given before the day can close.
*
* Only for a late close. Cash still sitting with a cashier is not something a reason excuses —
* see canSubmitClose.
*/
get needsForceReason(): boolean {
return this.isLateClose;
}
get canSubmitClose(): boolean {
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
if (!this.closeTarget || !hasFigure || this.closing) return false;
// Block while cashier/vault data is still loading — the reconciliation check can't run yet.
if (this.eodLoading || this.previewLoading) return false;
// An unreconciled cashier is a reason-required condition, not an absolute block: the backend
// accepts the close once a reason is recorded (and appends the cashiers' names to it), and
// refusing outright left a day that could never be closed at all — a cashier who reconciled on
// the wrong date, went on leave, or left mid-shift would strand that day and every day after it.
if (this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) {
return !!this.forceReason.trim();
}
return true;
}
submitClose(): void {
if (this.eodLoading || this.previewLoading) {
this.errorMsg = 'Still loading the cashier and vault positions — try again in a moment.';
return;
}
// Safety net mirroring canSubmitClose: never send a close that the backend will refuse for a
// missing override reason.
if ((this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) && !this.forceReason.trim()) {
this.errorMsg = 'Record why this day is being closed before submitting.';
return;
}
const day = this.closeTarget;
const denominations = this.denominationPayload(this.closeCounts);
const figure = denominations.length ? this.closeDenomTotal : this.closingCash;
if (!day || figure == null) return;
this.closing = true;
this.errorMsg = '';
this.service
.closeBranchDay(
day.businessDate,
Number(figure),
this.closeRemarks.trim() || undefined,
// Sent whenever one was typed, not only on a late close: it is also the override the backend
// needs for a same-day close over an unreconciled cashier, and gating it on isLateClose
// dropped it on exactly the request that could not succeed without it.
this.forceReason.trim() || undefined,
undefined,
denominations
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.';
return of(null);
})
)
.subscribe((res: any) => {
this.closing = false;
if (res) {
this.closeTarget = null;
const diff = Number(res.cashDifference ?? 0);
this.successMsg =
diff === 0
? `Business day ${res.businessDate} closed — cash tallied.`
: `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`;
this.load();
}
});
}
absValue(n: number): number {
return Math.abs(n);
}
/** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */
differenceLabel(d: number | null): string {
if (d == null) return '—';
const n = Number(d);
if (n === 0) return 'Tallied';
return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`;
}
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
timeOnly(iso: string | null): string {
if (!iso) return '—';
const t = iso.split('T')[1];
return t ? t.slice(0, 5) : '—';
}
}