Files
cbsnexora/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.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

263 lines
8.8 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, Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../../cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface Denom {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
/**
* Migrate Cash Denomination — the vault-side counterpart to Migrate Opening Balances.
*
* A freshly set-up institution's chart of accounts carries a "Cash at Vault" GL balance (defined via
* Migrate Opening Balances, or not yet defined at all), but that figure is meaningless to a cashier
* standing at the drawer until it is broken into actual notes and coins, and the branch has nowhere
* to track that breakdown until its first business day is open. This screen is the one place both of
* those happen together: count the drawer, and it either defines the cash GL's opening balance from
* that count (nothing posted to it yet) or checks the count against what is already posted, then
* opens the business day with that same breakdown - so a new bank goes from "GL seeded" straight to
* "vault countable" without a separate trip through Manage Tellers or Open Business Day first.
*/
@Component({
selector: 'mifosx-migrate-cash-denomination',
templateUrl: './migrate-cash-denomination.component.html',
styleUrls: ['./migrate-cash-denomination.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
...STANDALONE_SHARED_IMPORTS
]
})
export class MigrateCashDenominationComponent implements OnInit {
private service = inject(CashierService);
private settingsService = inject(SettingsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
officeData: any[] = [];
officeId: number | null = null;
/** Bound to the datepicker as a Date, converted to a strict yyyy-MM-dd string only when
* submitting — the branchday endpoints parse this with LocalDate.parse() directly, with no
* locale/dateFormat hint alongside it, so a locale-formatted string (what dateUtils.formatDate
* produces) is rejected outright. */
businessDate: Date = new Date();
maxDate = new Date();
glLoading = false;
glStatus: any = null;
glError = '';
/** Set once per office change: whether TODAY's business day is already open for this office. This
* screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened,
* reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination
* itself refuses (its own openDayInternal call rejects a day that is already open), so this is
* checked here too, before the user counts an entire drawer only to have the submit rejected. */
todayAlreadyOpen = false;
todayOpenInfo: any = null;
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record<number, number> = {};
submitting = false;
errorMsg = '';
successMsg = '';
result: any = null;
/** Only asked for when the counted total disagrees with an already-posted GL balance — mirrors
* the same "explain the gap" gate Open Business Day applies. */
differenceReason = '';
get allDenoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.businessDate = this.maxDate;
this.route.data.subscribe((data: { offices: any }) => {
this.officeData = data.offices || [];
});
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 = [];
}
});
}
onOfficeChange(): void {
this.glStatus = null;
this.glError = '';
this.result = null;
this.successMsg = '';
this.errorMsg = '';
this.todayAlreadyOpen = false;
this.todayOpenInfo = null;
if (!this.officeId) return;
this.glLoading = true;
this.service
.cashGlStatus(this.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glLoading = false;
this.glStatus = res;
if (res && res.configured === false) {
this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
}
});
this.service
.branchDayRegister(this.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const openDay = res?.openDay;
this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today;
this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null;
});
}
countFor(d: Denom): number {
return this.counts[d.id] || 0;
}
subtotalFor(d: Denom): number {
return this.countFor(d) * d.value;
}
onCountChange(d: Denom, raw: any): void {
this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpCount(d: Denom, delta: number): void {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
/** Widens the count box as the typed count grows more digits, so a large count (hundreds,
* thousands) is never clipped or hard to read inside a field sized for one or two digits. */
countInputWidthCh(d: Denom): number {
return Math.max(3, String(this.countFor(d)).length + 2);
}
get countedTotal(): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
}
/** Whether this office's GL already carries a balance the count has to match, vs. an empty
* account the count itself will define. */
get glHasBalance(): boolean {
return !!this.glStatus?.hasBalance;
}
get glTarget(): number {
return Number(this.glStatus?.currentBalance ?? 0);
}
get isReconciled(): boolean {
if (!this.glStatus || this.glStatus.configured === false) return false;
if (this.countedTotal <= 0) return false;
return !this.glHasBalance || this.countedTotal === this.glTarget;
}
get needsDifferenceReason(): boolean {
return (
this.glHasBalance && this.countedTotal > 0 && this.countedTotal !== this.glTarget && !this.differenceReason.trim()
);
}
get canSubmit(): boolean {
return (
!!this.officeId &&
!!this.businessDate &&
!this.glError &&
this.countedTotal > 0 &&
!this.needsDifferenceReason &&
!this.submitting
);
}
private denominationPayload() {
return this.allDenoms
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }));
}
/** Strict yyyy-MM-dd, independent of the viewer's locale/date-format setting — see the comment on
* `businessDate` for why this can't go through dateUtils.formatDate(). */
private toIsoDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
submit(): void {
if (!this.canSubmit || !this.officeId) return;
this.submitting = true;
this.errorMsg = '';
this.successMsg = '';
this.service
.migrateCashDenomination(
this.officeId,
this.toIsoDate(this.businessDate),
this.denominationPayload(),
this.differenceReason.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Could not migrate cash denomination.';
return of(null);
})
)
.subscribe((res: any) => {
this.submitting = false;
if (res) {
this.result = res;
this.successMsg = res.glOpeningBalancePosted
? `Cash GL opening balance defined at ₹${this.countedTotal.toLocaleString('en-IN')} and business day ${res.businessDate} opened.`
: `Business day ${res.businessDate} opened — count matched the existing GL balance.`;
}
});
}
goToBranchDay(): void {
this.router.navigate(['/branch-ops/day-register']);
}
}