feat(savings,shares): bidirectional savings/share account linking, new Migrate Cash Denomination screen
- Savings account creation shows a required "Default Share Account" picker once Share Linking is enabled, populated from the client's active share accounts; submits shareAccountId alongside it. - Fixes a real data-loss bug on Share Account creation: the Charges step re-seeded its table from the product's default charges on every ngOnChanges firing (e.g. stepping back to Details and forward again), silently discarding a charge the clerk had already added, edited, or removed - a pristine guard existed for exactly this but wasn't checked in ngOnChanges. Now it is. - Removes the hard "required" on Share Account creation's Default Savings Account field, matching the backend now accepting a share account with no linked savings account. - New /accounting/migrate-cash-denomination screen: counts a branch's opening cash into notes and coins, checks it against (or defines, if unset) the Cash GL account's balance, and opens the branch's first business day with that same count - the vault-side counterpart to Migrate Opening Balances, kept under Accounting rather than Branch Ops/Cashier so it isn't reachable by mistake. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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 = '';
|
||||
|
||||
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 = '';
|
||||
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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user