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:
2026-08-31 22:18:33 +05:30
co-authored by Claude Sonnet 5
parent 6624009d8c
commit 7a2cb25538
13 changed files with 830 additions and 20 deletions
@@ -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>
@@ -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>
@@ -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']);
}
}
+25
View File
@@ -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.'
});
}
}
});
@@ -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>
@@ -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;
}
+6
View File
@@ -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>}
@@ -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;
}
}
}
@@ -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">
@@ -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: [''],