From 7a2cb2553848a1a2519152c9960666be7fde713e Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 31 Aug 2026 22:18:33 +0530 Subject: [PATCH] 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 --- .../accounting/accounting-routing.module.ts | 9 + src/app/accounting/accounting.component.html | 16 + .../migrate-cash-denomination.component.html | 167 ++++++++++ .../migrate-cash-denomination.component.scss | 308 ++++++++++++++++++ .../migrate-cash-denomination.component.ts | 237 ++++++++++++++ src/app/cashier/cashier.service.ts | 25 ++ .../create-savings-account.component.ts | 15 + ...avings-account-details-step.component.html | 22 ++ .../savings-account-details-step.component.ts | 15 + src/app/savings/savings.service.ts | 6 + .../shares-account-charges-step.component.ts | 9 +- .../shares-account-terms-step.component.html | 9 +- .../shares-account-terms-step.component.ts | 12 +- 13 files changed, 830 insertions(+), 20 deletions(-) create mode 100644 src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html create mode 100644 src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss create mode 100644 src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts diff --git a/src/app/accounting/accounting-routing.module.ts b/src/app/accounting/accounting-routing.module.ts index 2a254c1ed..ed14fee6f 100644 --- a/src/app/accounting/accounting-routing.module.ts +++ b/src/app/accounting/accounting-routing.module.ts @@ -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' }, diff --git a/src/app/accounting/accounting.component.html b/src/app/accounting/accounting.component.html index eeb29f912..de5ec4653 100644 --- a/src/app/accounting/accounting.component.html +++ b/src/app/accounting/accounting.component.html @@ -88,6 +88,22 @@ + + +
+
+ Migrate Cash Denomination + + Count the vault opening cash into notes and coins, and open the first business day + +
+
+
diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html new file mode 100644 index 000000000..d6e56e66e --- /dev/null +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.html @@ -0,0 +1,167 @@ + + +
+ + + +
+ inventory_2 + + 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. + +
+
+ + Branch + + @for (office of officeData; track office) { + {{ office.name }} + } + + + + Business Date + + + + +
+
+
+ + @if (glLoading) { +
Checking the cash GL account…
+ } + + @if (glError) { +
+ error +
{{ glError }}
+
+ } + + @if (glStatus && !glError) { + +
+ account_balance +
+
Cash GL Account
+
({{ glStatus.glCode }}) {{ glStatus.glAccountName }}
+ @if (glHasBalance) { +
+ Already holds + ₹{{ glTarget.toLocaleString('en-IN') }} + — count the drawer to exactly this amount. +
+ } @else { +
+ No opening balance posted yet — whatever you count below becomes this account's opening balance. +
+ } +
+
+ + + + +
+
Denomination
+
Count
+
Amount
+
+ @for (d of allDenoms; track d.id) { +
+
+ {{ d.label }} {{ d.kind }} +
+
+ + + +
+
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
+
+ } +
+
+ + +
+
+ Counted Total + ₹{{ countedTotal.toLocaleString('en-IN') }} +
+ @if (glHasBalance) { +
+ GL Target + ₹{{ glTarget.toLocaleString('en-IN') }} +
+ } +
+ @if (countedTotal <= 0) { + edit_note + Count each denomination above + } @else if (isReconciled) { + check_circle + {{ glHasBalance ? 'Matches the GL balance' : 'Will define the GL opening balance' }} + } @else { + error + Off by ₹{{ + (countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN') + }} + } +
+
+ + @if (needsDifferenceReason) { + + + + Why does the count differ from the GL balance? + + + + + } + + @if (errorMsg) { +
{{ errorMsg }}
+ } + + @if (successMsg) { +
+ check_circle +
+ {{ successMsg }} +
+ +
+
+
+ } + + @if (!successMsg) { +
+ +
+ } + } +
diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss new file mode 100644 index 000000000..6dcf31aa0 --- /dev/null +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss @@ -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; + } + } +} diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts new file mode 100644 index 000000000..a66fb5651 --- /dev/null +++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts @@ -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 = {}; + + 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']); + } +} diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index a2f5cf910..7e18feab0 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -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 { + 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 { + 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 { let params = new HttpParams(); diff --git a/src/app/savings/create-savings-account/create-savings-account.component.ts b/src/app/savings/create-savings-account/create-savings-account.component.ts index 2ab7e094e..525c98f2b 100644 --- a/src/app/savings/create-savings-account/create-savings-account.component.ts +++ b/src/app/savings/create-savings-account/create-savings-account.component.ts @@ -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.' + }); } } }); diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html index 31b97c866..e15a7cfa1 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html @@ -126,6 +126,28 @@ No + + @if (shareLinkingEnabled) { + + Default Share Account + + @for (account of activeShareAccounts; track account.id) { + {{ account.accountNo }} - {{ account.productName }} + } + + @if (!shareAccountId) { + Default Share Account is required + } + @if (activeShareAccounts.length === 0) { + This client has no active share account. Open one first, or turn off Share Linking. + } + + } diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts index ebdea0abe..d9fa352f4 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts @@ -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; } diff --git a/src/app/savings/savings.service.ts b/src/app/savings/savings.service.ts index 26388a148..cad6b3892 100644 --- a/src/app/savings/savings.service.ts +++ b/src/app/savings/savings.service.ts @@ -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 { + return this.http.get(`/clients/${clientId}/activeshareaccounts`); + } + /** * @param {any} savingsAccount Savings Account * @returns {Observable} diff --git a/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts index 74fb50865..d5a89f5f5 100644 --- a/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts @@ -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; + } } } diff --git a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html index cb7278805..1f39fab6e 100644 --- a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html +++ b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.html @@ -30,20 +30,13 @@ {{ calculateCurrenValue() | currency: currency.code : 'symbol-narrow' : '1.2-2' }} } - {{ 'labels.inputs.Default Savings Account' | translate }} - + @for (account of savingsAccountsData; track account) { {{ account.accountNo }} - {{ account.savingsProductName }} } - - {{ 'labels.inputs.Default Savings Account' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - @if (savingsAccountsData?.length === 0) {

diff --git a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts index f73f9d2fe..69215fdd1 100644 --- a/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-terms-step/shares-account-terms-step.component.ts @@ -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: [''],