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:
@@ -25,6 +25,7 @@ import { CreateFinancialActivityMappingComponent } from './financial-activity-ma
|
||||
import { ViewFinancialActivityMappingComponent } from './financial-activity-mappings/view-financial-activity-mapping/view-financial-activity-mapping.component';
|
||||
import { EditFinancialActivityMappingComponent } from './financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component';
|
||||
import { MigrateOpeningBalancesComponent } from './migrate-opening-balances/migrate-opening-balances.component';
|
||||
import { MigrateCashDenominationComponent } from './migrate-cash-denomination/migrate-cash-denomination.component';
|
||||
import { ChartOfAccountsComponent } from './chart-of-accounts/chart-of-accounts.component';
|
||||
import { CreateGlAccountComponent } from './chart-of-accounts/create-gl-account/create-gl-account.component';
|
||||
import { ViewGlAccountComponent } from './chart-of-accounts/view-gl-account/view-gl-account.component';
|
||||
@@ -185,6 +186,14 @@ const routes: Routes = [
|
||||
currencies: CurrenciesResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'migrate-cash-denomination',
|
||||
component: MigrateCashDenominationComponent,
|
||||
data: { title: 'Migrate Cash Denomination', breadcrumb: 'Migrate Cash Denomination' },
|
||||
resolve: {
|
||||
offices: OfficesResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'chart-of-accounts',
|
||||
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
|
||||
|
||||
@@ -88,6 +88,22 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Migrate Cash Denomination -->
|
||||
<a
|
||||
class="acc-card"
|
||||
[routerLink]="['migrate-cash-denomination']"
|
||||
*mifosxHasPermission="'READ_JOURNALENTRY'"
|
||||
#migrateCashDenomination
|
||||
>
|
||||
<div class="acc-card__icon amber"><i class="fas fa-coins"></i></div>
|
||||
<div class="acc-card__info">
|
||||
<span class="acc-card__title">Migrate Cash Denomination</span>
|
||||
<span class="acc-card__desc">
|
||||
Count the vault opening cash into notes and coins, and open the first business day
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Chart of Accounts -->
|
||||
<a class="acc-card" [routerLink]="['chart-of-accounts']" *mifosxHasPermission="'READ_GLACCOUNT'" #chartofAccounts>
|
||||
<div class="acc-card__icon blue"><i class="fas fa-sitemap"></i></div>
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container mcd">
|
||||
<!-- Step 1: which branch's vault is being counted. -->
|
||||
<mat-card class="mcd-card">
|
||||
<mat-card-content>
|
||||
<div class="mcd-intro">
|
||||
<mat-icon>inventory_2</mat-icon>
|
||||
<span>
|
||||
Count the branch's opening cash into notes and coins. If the Cash GL account has no balance yet, this count
|
||||
defines it; if it already carries one, the count must match it exactly. Either way, this also opens the
|
||||
branch's first business day with the same count.
|
||||
</span>
|
||||
</div>
|
||||
<div class="mcd-row">
|
||||
<mat-form-field class="mcd-office">
|
||||
<mat-label>Branch</mat-label>
|
||||
<mat-select required [(ngModel)]="officeId" (selectionChange)="onOfficeChange()">
|
||||
@for (office of officeData; track office) {
|
||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="mcd-field" (click)="datePicker.open()">
|
||||
<mat-label>Business Date</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="datePicker" [(ngModel)]="businessDate" readonly />
|
||||
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #datePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@if (glLoading) {
|
||||
<div class="mcd-loading">Checking the cash GL account…</div>
|
||||
}
|
||||
|
||||
@if (glError) {
|
||||
<div class="mcd-gl mcd-gl--error">
|
||||
<mat-icon>error</mat-icon>
|
||||
<div>{{ glError }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (glStatus && !glError) {
|
||||
<!-- Step 2: what the ledger already says, so the count is counted against something concrete. -->
|
||||
<div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
<div>
|
||||
<div class="mcd-gl__label">Cash GL Account</div>
|
||||
<div class="mcd-gl__value">({{ glStatus.glCode }}) {{ glStatus.glAccountName }}</div>
|
||||
@if (glHasBalance) {
|
||||
<div class="mcd-gl__hint">
|
||||
Already holds
|
||||
<strong>₹{{ glTarget.toLocaleString('en-IN') }}</strong>
|
||||
— count the drawer to exactly this amount.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mcd-gl__hint">
|
||||
No opening balance posted yet — whatever you count below becomes this account's opening balance.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: the actual count. -->
|
||||
<mat-card class="mcd-card mcd-card--flush">
|
||||
<mat-card-content>
|
||||
<div class="mcd-grid mcd-grid--head">
|
||||
<div class="mcd-grid__denom">Denomination</div>
|
||||
<div class="mcd-grid__count">Count</div>
|
||||
<div class="mcd-grid__amt">Amount</div>
|
||||
</div>
|
||||
@for (d of allDenoms; track d.id) {
|
||||
<div class="mcd-grid mcd-grid--row">
|
||||
<div class="mcd-grid__denom">
|
||||
{{ d.label }} <span class="mcd-grid__kind">{{ d.kind }}</span>
|
||||
</div>
|
||||
<div class="mcd-grid__count">
|
||||
<button type="button" mat-icon-button (click)="bumpCount(d, -1)"><mat-icon>remove</mat-icon></button>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
class="mcd-grid__input"
|
||||
[ngModel]="countFor(d)"
|
||||
(ngModelChange)="onCountChange(d, $event)"
|
||||
/>
|
||||
<button type="button" mat-icon-button (click)="bumpCount(d, 1)"><mat-icon>add</mat-icon></button>
|
||||
</div>
|
||||
<div class="mcd-grid__amt">₹{{ subtotalFor(d).toLocaleString('en-IN') }}</div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- The one number that matters. -->
|
||||
<div class="mcd-balance" [class.mcd-balance--ok]="isReconciled">
|
||||
<div class="mcd-balance__figure">
|
||||
<span class="mcd-balance__label">Counted Total</span>
|
||||
<span class="mcd-balance__amt">₹{{ countedTotal.toLocaleString('en-IN') }}</span>
|
||||
</div>
|
||||
@if (glHasBalance) {
|
||||
<div class="mcd-balance__figure">
|
||||
<span class="mcd-balance__label">GL Target</span>
|
||||
<span class="mcd-balance__amt">₹{{ glTarget.toLocaleString('en-IN') }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="mcd-balance__status">
|
||||
@if (countedTotal <= 0) {
|
||||
<mat-icon>edit_note</mat-icon>
|
||||
<span>Count each denomination above</span>
|
||||
} @else if (isReconciled) {
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>{{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }}</span>
|
||||
} @else {
|
||||
<mat-icon>error</mat-icon>
|
||||
<span
|
||||
>Off by ₹{{
|
||||
(countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN')
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (needsDifferenceReason) {
|
||||
<mat-card class="mcd-card">
|
||||
<mat-card-content>
|
||||
<mat-form-field class="mcd-reason">
|
||||
<mat-label>Why does the count differ from the GL balance?</mat-label>
|
||||
<input matInput [(ngModel)]="differenceReason" />
|
||||
</mat-form-field>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="mcd-error">{{ errorMsg }}</div>
|
||||
}
|
||||
|
||||
@if (successMsg) {
|
||||
<div class="mcd-success">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<div>
|
||||
{{ successMsg }}
|
||||
<div class="mcd-success__actions">
|
||||
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!successMsg) {
|
||||
<div class="mcd-actions">
|
||||
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
|
||||
{{ submitting ? 'Submitting…' : 'Migrate & Open Business Day' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.mcd {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.mcd-card {
|
||||
margin-bottom: 14px;
|
||||
border-radius: 8px;
|
||||
|
||||
&--flush mat-card-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mcd-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
color: #1e40af;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mcd-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mcd-office {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.mcd-field {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.mcd-loading {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mcd-gl {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #0ea5e9;
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
color: #0ea5e9;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--fresh {
|
||||
border-left-color: #f59e0b;
|
||||
|
||||
mat-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-left-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
|
||||
mat-icon {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.mcd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px 120px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
&--head {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--row {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
&__denom {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__kind {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__amt {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
.mcd-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 8px;
|
||||
|
||||
&--ok {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
border-left-color: #16a34a;
|
||||
}
|
||||
|
||||
&__figure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__amt {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #b91c1c;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&--ok &__status {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.mcd-reason {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mcd-error {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.mcd-success {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
color: #14532d;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
color: #16a34a;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mcd-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (width <= 560px) {
|
||||
.mcd-grid {
|
||||
grid-template-columns: 1fr auto;
|
||||
row-gap: 4px;
|
||||
|
||||
&--head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__amt {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,31 @@ export class CashierService {
|
||||
return this.http.get('/branchday/denominations', { params });
|
||||
}
|
||||
|
||||
/** What the office's cash-at-vault GL account currently holds — zero means the count itself will
|
||||
* define its opening balance; non-zero means the count must match it exactly. */
|
||||
cashGlStatus(officeId?: number): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (officeId) params = params.set('officeId', String(officeId));
|
||||
return this.http.get('/branchday/cash-gl-status', { params });
|
||||
}
|
||||
|
||||
/** One-step new-institution path: defines (or reconciles against) the cash GL's opening balance
|
||||
* from a counted denomination breakdown, then opens the business day with that same count. */
|
||||
migrateCashDenomination(
|
||||
officeId: number,
|
||||
businessDate: string,
|
||||
denominations: { denominationId: number; denominationValue: number; count: number }[],
|
||||
openingDifferenceReason?: string
|
||||
): Observable<any> {
|
||||
return this.http.post('/branchday/migrate-cash-denomination', {
|
||||
officeId,
|
||||
businessDate,
|
||||
denominations,
|
||||
openingDifferenceReason: openingDifferenceReason ?? null,
|
||||
locale: 'en'
|
||||
});
|
||||
}
|
||||
|
||||
/** Denomination-wise stock a vault (teller) currently holds — "how much of what does the bank have". */
|
||||
vaultPosition(tellerId?: number, asOfDate?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
|
||||
@@ -146,6 +146,13 @@ export class CreateSavingsAccountComponent {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (detailsStep.shareLinkingEnabled && !detailsStep.shareAccountId) {
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message: 'Share Linking is enabled — please select the share account to link before submitting.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const monthDayFormat = 'dd MMMM';
|
||||
@@ -208,6 +215,14 @@ export class CreateSavingsAccountComponent {
|
||||
type: 'Error',
|
||||
message: 'An account with this External ID already exists. Please use a unique External ID.'
|
||||
});
|
||||
} else if (
|
||||
errorCode === 'error.msg.savingsaccount.share.account.required' ||
|
||||
errorCode === 'error.msg.savingsaccount.share.account.not.active.for.client'
|
||||
) {
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message: 'Please select an active share account belonging to this client before submitting.'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+22
@@ -126,6 +126,28 @@
|
||||
<mat-option [value]="false">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@if (shareLinkingEnabled) {
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>Default Share Account</mat-label>
|
||||
<mat-select
|
||||
required
|
||||
[(ngModel)]="shareAccountId"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="readOnly"
|
||||
>
|
||||
@for (account of activeShareAccounts; track account.id) {
|
||||
<mat-option [value]="account.id"> {{ account.accountNo }} - {{ account.productName }} </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (!shareAccountId) {
|
||||
<mat-error>Default Share Account is <strong>required</strong></mat-error>
|
||||
}
|
||||
@if (activeShareAccounts.length === 0) {
|
||||
<mat-hint>This client has no active share account. Open one first, or turn off Share Linking.</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+15
@@ -78,6 +78,11 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
|
||||
|
||||
productTemplate: any = null;
|
||||
shareLinkingEnabled = false;
|
||||
/** The client's active share accounts, offered as the "Default Share Account" once Share Linking
|
||||
* is enabled — the reverse of the "Default Savings Account" picker Share Account creation
|
||||
* already shows. Fetched once for the client this form is for, since it never changes mid-form. */
|
||||
activeShareAccounts: any[] = [];
|
||||
shareAccountId: number | null = null;
|
||||
accountTypeOptions: string[] = [];
|
||||
customerSegmentOptions: string[] = [];
|
||||
selectedAccountType: string | null = null;
|
||||
@@ -209,6 +214,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
|
||||
const clientId = this.savingsAccountTemplate?.clientId;
|
||||
if (clientId) {
|
||||
this.primaryClientId = clientId;
|
||||
this.savingsService.getClientActiveShareAccounts(clientId).subscribe({
|
||||
next: (accounts) => (this.activeShareAccounts = accounts || []),
|
||||
error: () => (this.activeShareAccounts = [])
|
||||
});
|
||||
this.clientsService.getClientData(String(clientId)).subscribe((client: any) => {
|
||||
this.primaryClientName = client?.displayName ?? client?.fullname ?? '';
|
||||
this.primaryClientCif = client?.cifNumber ?? client?.cifNo ?? '';
|
||||
@@ -450,6 +459,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
|
||||
if (ea.shareLinkingEnabled !== undefined) {
|
||||
this.shareLinkingEnabled = !!ea.shareLinkingEnabled;
|
||||
}
|
||||
if (ea.shareAccountId) {
|
||||
this.shareAccountId = ea.shareAccountId;
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
@@ -669,6 +681,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
value.shareLinkingEnabled = this.shareLinkingEnabled;
|
||||
if (this.shareLinkingEnabled && this.shareAccountId) {
|
||||
value.shareAccountId = this.shareAccountId;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,12 @@ export class SavingsService {
|
||||
return this.http.get('/savingsaccounts/template', { params: httpParams });
|
||||
}
|
||||
|
||||
/** A client's active share accounts — for the "Default Share Account" picker shown once Share
|
||||
* Linking is enabled on a savings account being created. */
|
||||
getClientActiveShareAccounts(clientId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/activeshareaccounts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} savingsAccount Savings Account
|
||||
* @returns {Observable<any>}
|
||||
|
||||
+8
-1
@@ -111,7 +111,14 @@ export class SharesAccountChargesStepComponent implements OnInit, OnChanges {
|
||||
ngOnChanges() {
|
||||
if (this.sharesAccountProductTemplate) {
|
||||
this.chargeData = this.sharesAccountTemplate.chargeOptions;
|
||||
this.chargesDataSource = this.sharesAccountProductTemplate.charges;
|
||||
// Re-seeding from the product's own charges is only correct the first time this template
|
||||
// arrives (picking the product). The productId control's valueChanges fires again on any
|
||||
// later patchValue with the same id - stepping back to Details and forward again, for
|
||||
// instance - and re-emits this same template; without this guard, that silently wipes
|
||||
// whatever the user had already added, edited, or removed on this step.
|
||||
if (this.pristine) {
|
||||
this.chargesDataSource = this.sharesAccountProductTemplate.charges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-8
@@ -30,20 +30,13 @@
|
||||
<span class="flex-48">{{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }}</span>
|
||||
}
|
||||
|
||||
<!-- No "None" option: the server rejects a share account without a linked savings account, and
|
||||
offering the choice only produced a failure after the whole stepper was filled in. The link
|
||||
is also where an over-payment at the counter is credited back to the member. -->
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label>
|
||||
<mat-select formControlName="savingsAccountId" required>
|
||||
<mat-select formControlName="savingsAccountId">
|
||||
@for (account of savingsAccountsData; track account) {
|
||||
<mat-option [value]="account.id"> {{ account.accountNo }} - {{ account.savingsProductName }} </mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Default Savings Account' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
@if (savingsAccountsData?.length === 0) {
|
||||
<p class="sats-field-note flex-98">
|
||||
|
||||
+1
-11
@@ -128,17 +128,7 @@ export class SharesAccountTermsStepComponent implements OnChanges, OnInit {
|
||||
],
|
||||
unitPrice: [{ value: '', disabled: true }],
|
||||
currencyMultiple: [{ value: '', disabled: true }],
|
||||
// Required to match the server, which has always rejected a share account without a linked
|
||||
// savings account. The form allowed one through, so the failure surfaced as a validation
|
||||
// error on submit after the whole stepper had been filled in.
|
||||
//
|
||||
// It also gives an over-payment somewhere to go: a member who hands over more cash than the
|
||||
// application costs has the difference credited to this account, rather than being left in a
|
||||
// suspense GL that nobody can attribute back to them.
|
||||
savingsAccountId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
savingsAccountId: [''],
|
||||
minimumActivePeriod: [''],
|
||||
minimumActivePeriodFrequencyType: [''],
|
||||
lockinPeriodFrequency: [''],
|
||||
|
||||
Reference in New Issue
Block a user