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>
196 lines
6.9 KiB
TypeScript
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 };
|
|
}
|
|
}
|