Files
cbsnexora/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts
T
niteshandClaude Sonnet 5 811c535d99
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
fix(shares): persist product-default charges on submit; show real charge state; office-delete UX
Two share-account charge bugs found while chasing why a share account's
entry-fee charge wasn't showing up in the "Apply Share Advance
Collection" flow:

- create-shares-account.component.ts / edit-shares-account.component.ts:
  the submit payload built charges as { chargeId: charge.id, amount }.
  A charge seeded from the product's own default template arrives as
  ShareAccountChargeData, where the real charge id lives in `chargeId`
  and `id` is null (no saved ShareAccountCharge row yet); only a charge
  picked from this step's own "Charge" dropdown (plain ChargeData) has
  it in `id`. So `charge.id` alone silently produced chargeId: undefined
  for the common case (accepting the product's own default charges),
  Angular's JSON.stringify dropped the undefined field, and the backend
  received a charge with no id to attach -- the account got created with
  zero persisted charges no matter what the product had configured.
  Verified via the actual submitted command JSON in the DB
  (charges:[{"amount":200}], chargeId missing entirely). Fixed by
  falling back to charge.chargeId || charge.id in both places.

- shares-account-charges-step.component.ts: on the "edit an existing
  account" flow, the account's real saved charges (possibly a correctly
  empty array) get patched into chargesDataSource once the shared
  currencyCode control settles, but that patch never cleared the
  `pristine` guard. ngOnChanges fires again on the same input-update
  cycle (the sibling Terms step, declared earlier in the wizard, patches
  currencyCode from its own ngOnChanges, which synchronously triggers
  this component's currencyCode subscription, and Angular then runs
  this component's own ngOnChanges right after) -- with `pristine` still
  true, that re-seeded chargesDataSource from the product's *current*
  default template, silently overwriting the real (possibly empty)
  saved state. Set pristine = false once the real charges are patched
  in, so a later ngOnChanges can't clobber it.

Also: offices.component.ts now distinguishes an office delete that
actually completed from one that was queued for checker approval
(response.rollbackTransaction, the same signal every other
maker-checker-eligible screen in this app already keys off), instead of
always claiming "Office Removed" regardless of which happened.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:18:54 +05:30

196 lines
6.9 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, OnChanges, Input, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UntypedFormControl } from '@angular/forms';
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
/** Custom Models */
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { TranslateService } from '@ngx-translate/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Shares Account Charges Step
*/
@Component({
selector: 'mifosx-shares-account-charges-step',
templateUrl: './shares-account-charges-step.component.html',
styleUrls: ['./shares-account-charges-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatIconButton,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatStepperPrevious,
MatStepperNext,
ChargesFilterPipe
]
})
export class SharesAccountChargesStepComponent implements OnInit, OnChanges {
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
/** Shares Account Product Template */
@Input() sharesAccountProductTemplate: any;
/** Shares Account Template */
@Input() sharesAccountTemplate: any;
/** Currency Code */
@Input() currencyCode: UntypedFormControl;
/** When true (Share Account "Edit Details" maker-checker flow), charges are locked - they aren't
* part of that flow's editable field set. */
@Input() approvalEditMode = false;
/** Charge Data */
chargeData: any = [];
/** Charges Data Source */
chargesDataSource: {}[] = [];
/** Component is pristine if there has been no changes by user interaction */
pristine = true;
/** For Edit Shares Account Form */
isChargesPatched = false;
/** Display columns for charges table */
displayedColumns: string[] = [
'name',
'chargeCalculationType',
'amount',
'chargeTimeType',
'action'
];
ngOnInit() {
// Only relevant to the "edit an existing application" flow, where sharesAccountTemplate.charges
// carries the charges the account was actually submitted with, patched back in once the currency
// control settles. For a brand-new account, sharesAccountTemplate never carries a charges array at
// all -- this must NOT fall back to wiping chargesDataSource, or it stomps the product's own
// charges that ngOnChanges just seeded moments earlier from the same product-selection event.
this.currencyCode.valueChanges.subscribe(() => {
if (!this.isChargesPatched && this.sharesAccountTemplate.charges) {
this.chargesDataSource = this.sharesAccountTemplate.charges;
this.isChargesPatched = true;
// The account's real saved charges just landed (possibly an empty array, meaning the
// account genuinely has none). ngOnChanges fires again on the very same input-update cycle
// that triggered this subscription (currencyCode.patchValue is called from the terms step's
// own ngOnChanges, which runs before this component's) -- without this, its `pristine` guard
// would still be true and silently re-seed chargesDataSource from the product's *current*
// default template, overwriting the real state this just patched in.
this.pristine = false;
}
});
}
ngOnChanges() {
if (this.sharesAccountProductTemplate) {
this.chargeData = this.sharesAccountTemplate.chargeOptions;
// 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;
}
}
}
/**
* Adds the charge to charges table
* @param {any} charge Charge
*/
addCharge(charge: any) {
this.chargesDataSource = this.chargesDataSource.concat([charge.value]);
charge.value = '';
this.pristine = false;
}
/**
* Edits Charge Amount
* @param {any} charge Charge
*/
editCharge(charge: any) {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'amount',
label: this.translateService.instant('labels.inputs.Amount'),
value: charge.amount || charge.amountOrPercentage,
type: 'number',
required: false
})
];
const data = {
title: this.translateService.instant('labels.heading.Edit Charge'),
layout: { addButtonText: 'Submit' },
formfields: formfields
};
const editNoteDialogRef = this.dialog.open(FormDialogComponent, { data });
editNoteDialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
const newCharge = { ...charge, amount: response.data.value.amount };
this.chargesDataSource.splice(this.chargesDataSource.indexOf(charge), 1, newCharge);
this.chargesDataSource = this.chargesDataSource.concat([]);
}
});
this.pristine = false;
}
/**
* Removes the charge from charges table
* @param {any} charge Charge
*/
deleteCharge(charge: any) {
const deleteChargeDialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `charge ${charge.name}` }
});
deleteChargeDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.chargesDataSource.splice(this.chargesDataSource.indexOf(charge), 1);
this.chargesDataSource = this.chargesDataSource.concat([]);
}
});
this.pristine = false;
}
/**
* Returns shares account charges
*/
get sharesAccountCharges() {
return { charges: this.chargesDataSource };
}
}