Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/loans/common-resolvers/loan-action-button.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export class LoanActionButtonResolver {
return this.loansService.getLoanActionTemplate(loanId, 'reAmortization');
} else if (loanActionButton === 'Attach Loan Originator') {
return this.organizationService.getLoanOriginators();
} else if (loanActionButton === 'Update discount') {
return this.loansService.getWorkingCapitalLoanDetails(loanId);
} else {
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,6 @@
@if (actions['Attach Loan Originator']) {
<mifosx-attach-originator [dataObject]="actionButtonData"></mifosx-attach-originator>
}
@if (actions['Update discount']) {
<mifosx-update-discount [dataObject]="actionButtonData"></mifosx-update-discount>
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { UndoWriteOffComponent } from './undo-write-off/undo-write-off.component
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AttachOriginatorComponent } from './attach-originator/attach-originator.component';
import { LoanProductBaseComponent } from 'app/products/loan-products/common/loan-product-base.component';
import { UpdateDiscountComponent } from './update-discount/update-discount.component';

/**
* Loan Account Actions component.
Expand Down Expand Up @@ -82,7 +83,8 @@ import { LoanProductBaseComponent } from 'app/products/loan-products/common/loan
LoanReamortizeComponent,
AddInterestPauseComponent,
UndoWriteOffComponent,
AttachOriginatorComponent
AttachOriginatorComponent,
UpdateDiscountComponent
]
})
export class LoanAccountActionsComponent {
Expand Down Expand Up @@ -134,6 +136,7 @@ export class LoanAccountActionsComponent {
'Buy Down Fee': boolean;
'Undo Write-off': boolean;
'Attach Loan Originator': boolean;
'Update discount': boolean;
} = {
Close: false,
'Undo Approval': false,
Expand Down Expand Up @@ -174,7 +177,8 @@ export class LoanAccountActionsComponent {
'Contract Termination': false,
'Buy Down Fee': false,
'Undo Write-off': false,
'Attach Loan Originator': false
'Attach Loan Originator': false,
'Update discount': false
};

actionButtonData: any;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<!--
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 mat-elevation-z8">
<mat-card>
<form [formGroup]="updateDiscountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Discount' | translate }}</mat-label>
<input
matInput
type="number"
min="0"
step="0.01"
required
mifosxPositiveNumber
formControlName="discountAmount"
/>
@if (updateDiscountForm.controls.discountAmount.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Discount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (updateDiscountForm.controls.discountAmount.hasError('min')) {
<mat-error>{{ 'labels.inputs.Discount must be greater than or equal to 0' | translate }}</mat-error>
}
@if (updateDiscountForm.controls.discountAmount.hasError('highAmountValue')) {
<mat-error>{{ 'labels.inputs.Please enter a valid number' | translate }}</mat-error>
}
</mat-form-field>

<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
<mat-hint align="end">
{{ updateDiscountForm.controls.note.value?.length || 0 }}/{{ maxNoteLength }}
</mat-hint>
@if (updateDiscountForm.controls.note.hasError('maxlength')) {
<mat-error>{{ 'labels.inputs.Note is too long' | translate }}</mat-error>
}
</mat-form-field>

@if (submitErrorMessage) {
<mat-error>{{ submitErrorMessage }}</mat-error>
}
</div>

<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button (click)="gotoLoanDefaultView()" [disabled]="isSubmitting">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
[disabled]="!updateDiscountForm.valid || isSubmitting"
*mifosxHasPermission="'UPDATEDISCOUNT_WORKINGCAPITALLOAN'"
>
@if (isSubmitting) {
<mat-spinner diameter="20"></mat-spinner>
}
@if (!isSubmitting) {
{{ 'labels.buttons.Submit' | translate }}
}
</button>
</mat-card-actions>
</mat-card-content>
</form>
</mat-card>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* 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, inject } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';

/** Custom Services */
import { AlertService } from 'app/core/alert/alert.service';
import { amountValueValidator } from 'app/shared/validators/amount-value.validator';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { PositiveNumberDirective } from 'app/directives/positive-number.directive';
import { LoanAccountActionsBaseComponent } from '../loan-account-actions-base.component';
import { WorkingCapitalLoanDiscountUpdateRequest } from 'app/loans/loans.service';

/**
* Update discount action for Working Capital Loan.
*/
@Component({
selector: 'mifosx-update-discount',
standalone: true,
templateUrl: './update-discount.component.html',
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize,
PositiveNumberDirective
]
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export class UpdateDiscountComponent extends LoanAccountActionsBaseComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private alertService = inject(AlertService);
private translateService = inject(TranslateService);

readonly maxNoteLength = 500;

updateDiscountForm: UntypedFormGroup;
isSubmitting = false;
submitErrorMessage = '';
discountValue = 0;

ngOnInit(): void {
this.discountValue = this.dataObject?.discount ?? this.dataObject?.discountAmount ?? 0;
this.updateDiscountForm = this.formBuilder.group({
discountAmount: [
this.discountValue,
[
Validators.required,
Validators.min(0),
amountValueValidator()
]
],
note: [
'',
Validators.maxLength(this.maxNoteLength)
]
});
}

submit(): void {
if (!this.updateDiscountForm.valid || this.isSubmitting) {
return;
}

this.isSubmitting = true;
this.submitErrorMessage = '';

const formValue = this.updateDiscountForm.value;
const payload: WorkingCapitalLoanDiscountUpdateRequest = {
discountAmount: Number(formValue.discountAmount),
note: formValue.note,
locale: this.settingsService.language.code,
dateFormat: this.settingsService.dateFormat
};

this.loanService.updateWorkingCapitalLoanDiscount(this.loanId, payload).subscribe({
next: () => {
this.alertService.alert({
type: 'Success',
message: this.translateService.instant('labels.messages.workingCapitalDiscountUpdated')
});
this.isSubmitting = false;
this.gotoLoanDefaultView();
},
error: (error: HttpErrorResponse) => {
this.submitErrorMessage = this.mapDiscountError(error);
this.isSubmitting = false;
}
});
}

private mapDiscountError(error: HttpErrorResponse): string {
const backendError = error?.error?.errors?.[0];
return (
backendError?.defaultUserMessage ||
error?.error?.defaultUserMessage ||
this.translateService.instant('labels.messages.unableToUpdateDiscount')
);
}
}
14 changes: 14 additions & 0 deletions src/app/loans/loans-view/loans-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,13 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
return;
}
this.buttonConfig = new LoansAccountButtonConfiguration(this.status, this.loanSubStatus);
if (this.canShowWorkingCapitalDiscountUpdate()) {
this.buttonConfig.addButton({
name: 'Update discount',
icon: 'edit',
taskPermissionName: 'UPDATEDISCOUNT_WORKINGCAPITALLOAN'
});
}
Comment on lines +260 to +266
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate discount action wiring =="
rg -n --type ts -C3 "Update discount|canShowWorkingCapitalDiscountUpdate|workingCapital.*discount|discount.*update" src/app/loans

echo
echo "== Inspect resolver for action payload =="
fd 'loan-action-button.resolver.ts$' src/app --exec sed -n '1,260p' {}

echo
echo "== Inspect update-discount component for date/one-time/pre-existing checks =="
fd 'update-discount.component.ts$' src/app --exec sed -n '1,320p' {}

echo
echo "== Inspect loans service methods used by resolver/component =="
fd 'loans.service.ts$' src/app --exec rg -n -C4 "getWorkingCapital|updateWorkingCapital|discount|disbursement|businessDate|already" {}

Repository: openMF/web-app

Length of output: 19810


🏁 Script executed:

# Get full implementation of canShowWorkingCapitalDiscountUpdate
sed -n '582,600p' src/app/loans/loans-view/loans-view.component.ts

echo "---"

# Check for any eligibility-related fields or conditions
rg -n "discount.*eligible|eligible.*discount|one.*time|disbursement.*date|discount.*created|discount.*approval" src/app/loans/loans-view --type ts

echo "---"

# Look at the loanDetailsData structure and what it contains
rg -n -A5 "loanDetailsData" src/app/loans/loans-view/loans-view.component.ts | head -60

echo "---"

# Check the update-discount template for any additional checks
fd 'update-discount.component.html$' src/app --exec cat {}

Repository: openMF/web-app

Length of output: 6507


Update discount action visibility is missing full eligibility checks.

The guard at line 582 checks only working-capital product type and active status, but the PR rules require additional constraints: disbursement date validation, one-time-only enforcement, and blocking when already added during creation/approval. The update-discount component's submit() method (lines 42–65) does not validate these constraints either, exposing the action when it should be unavailable. Ensure all three eligibility criteria are enforced before showing the action and before allowing submission.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/loans/loans-view/loans-view.component.ts` around lines 260 - 266,
canShowWorkingCapitalDiscountUpdate() currently only checks product type and
active status; extend it to also (1) validate the loan disbursement date meets
the required window, (2) enforce one-time-only discount (check existing discount
records/flags so discounts cannot be updated more than once), and (3) block the
action when a discount was already added during creation or approval workflows;
update the buttonConfig.addButton call to rely on this enhanced guard.
Additionally, in the UpdateDiscountComponent.submit() method, add the same three
validations (disbursement date, one-time-only, and creation/approval-added
check) and abort submission with an appropriate error/notification if any fail
so the backend/UX cannot apply the update when the action should be unavailable.


if (this.status === 'Submitted and pending approval') {
this.buttonConfig.addOption({
Expand Down Expand Up @@ -571,4 +578,11 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
}
return substatus.code === 'loanSubStatus.loanSubStatusType.contractTermination';
}

private canShowWorkingCapitalDiscountUpdate(): boolean {
if (!this.loanProductService.isWorkingCapital || !this.loanDetailsData) {
return false;
}
return this.loanDetailsData?.status?.active === true;
}
}
14 changes: 14 additions & 0 deletions src/app/loans/loans.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { DisbursementData } from './models/loan-account.model';

export interface WorkingCapitalLoanDiscountUpdateRequest {
discountAmount: number;
note?: string;
locale: string;
dateFormat: string;
}

/**
* Loans service.
*/
Expand Down Expand Up @@ -560,6 +567,13 @@ export class LoansService {
return this.http.get(`/working-capital-loans/${loanId}/template`, { params: httpParams });
}

updateWorkingCapitalLoanDiscount(
loanId: string | number,
data: WorkingCapitalLoanDiscountUpdateRequest
): Observable<any> {
return this.http.put(`/working-capital-loans/${loanId}/discount`, data);
}

guarantorAccountResource(loanId: string, clientId: any): Observable<any> {
const httpParams = new HttpParams().set('clientId', clientId);
return this.http.get(`/loans/${loanId}/guarantors/accounts/template`, { params: httpParams });
Expand Down
8 changes: 7 additions & 1 deletion src/assets/translations/cs-CS.json
Original file line number Diff line number Diff line change
Expand Up @@ -1801,6 +1801,7 @@
"Disbursement On": "Vyplácení zapnuto",
"Disbursement on": "Výplata zapnuta",
"Discount": "Sleva",
"Discount must be greater than or equal to 0": "Sleva musí být větší nebo rovna 0",
"Display Name": "Zobrazovaný název",
"Dividend Amount": "Částka dividendy",
"Dividend Period End Date": "Datum ukončení dividendového období",
Expand Down Expand Up @@ -2246,6 +2247,7 @@
"Not Available": "Není dostupný",
"Not Provided": "Není poskytnuto",
"Note": "Poznámka",
"Note is too long": "Poznámka je příliš dlouhá",
"Notes": "Poznámky",
"Notes/Comments": "Poznámky/Komentáře",
"Notification": "Oznámení",
Expand All @@ -2259,6 +2261,7 @@
"Number of Active Overdue Group Loans": "Počet aktivních skupinových půjček po splatnosti",
"Number of Centers": "Počet středisek",
"Number of Clients": "Počet klientů",
"Please enter a valid number": "Zadejte prosím platné číslo",
"Number of Collaterals": "Počet kolaterálů",
"Number of Days to Dormant sub-status": "Dílčí stav Počet dnů do klidového stavu",
"Number of Days to Escheat": "Počet dní do Escheat",
Expand Down Expand Up @@ -2979,6 +2982,7 @@
"Withdrawal": "Vybrání",
"Withdrawn by Client": "Staženo klientem",
"Write Off": "Odepsat",
"Update discount": "Aktualizovat slevu",
"Apply Additional Shares": "Použít další",
"Redeem Shares": "Uplatnit akcie",
"Approve Additional Shares": "Schválit další akcie",
Expand Down Expand Up @@ -4072,7 +4076,9 @@
"No Closed Share Accounts Found": "Žádné uzavřené účty podílů nalezeny",
"No Collateral Data Found": "Žádná data o kolaterálu nalezeny",
"No Identities Found": "Žádné identity nalezeny",
"validationSaved": "Data ověření byla úspěšně uložena."
"validationSaved": "Data ověření byla úspěšně uložena.",
"workingCapitalDiscountUpdated": "Sleva pro úvěr pracovního kapitálu byla úspěšně aktualizována.",
"unableToUpdateDiscount": "Slevu se nepodařilo aktualizovat."
},
"languages": {
"cs-CS": "Čeština",
Expand Down
8 changes: 7 additions & 1 deletion src/assets/translations/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -1804,6 +1804,7 @@
"Disbursement On": "Auszahlung erfolgt",
"Disbursement on": "Auszahlung am",
"Discount": "Rabatt",
"Discount must be greater than or equal to 0": "Der Rabatt muss größer oder gleich 0 sein",
"Display Name": "Anzeigename",
"Dividend Amount": "Dividendenbetrag",
"Dividend Period End Date": "Enddatum des Dividendenzeitraums",
Expand Down Expand Up @@ -2248,6 +2249,7 @@
"Not Available": "Nicht gefunden",
"Not Provided": "Nicht bereitgestellt",
"Note": "Notiz",
"Note is too long": "Die Notiz ist zu lang",
"Notes": "Anmerkungen",
"Notes/Comments": "Notizen/Kommentare",
"Notification": "Benachrichtigung",
Expand All @@ -2261,6 +2263,7 @@
"Number of Active Overdue Group Loans": "Anzahl der aktiven überfälligen Gruppenkredite",
"Number of Centers": "Anzahl der Zentren",
"Number of Clients": "Anzahl der Kunden",
"Please enter a valid number": "Bitte geben Sie eine gültige Zahl ein",
"Number of Collaterals": "Anzahl der Sicherheiten",
"Number of Days to Dormant sub-status": "Unterstatus „Anzahl der Tage bis zum Ruhezustand“.",
"Number of Days to Escheat": "Anzahl der Tage bis zur Pfändung",
Expand Down Expand Up @@ -2981,6 +2984,7 @@
"Withdrawal": "Rückzug",
"Withdrawn by Client": "Vom Kunden zurückgezogen",
"Write Off": "Abschreiben",
"Update discount": "Rabatt aktualisieren",
"Apply Additional Shares": "Zusätzlich anwenden",
"Redeem Shares": "Anteile zurückgeben",
"Approve Additional Shares": "Genehmigen Sie zusätzliche Freigaben",
Expand Down Expand Up @@ -4073,7 +4077,9 @@
"No Closed Share Accounts Found": "Keine geschlossenen Aktienkonten gefunden",
"No Collateral Data Found": "Keine Sicherheiten gefunden",
"No Identities Found": "Keine Identitäten gefunden",
"validationSaved": "Validierungsdaten wurden erfolgreich gespeichert."
"validationSaved": "Validierungsdaten wurden erfolgreich gespeichert.",
"workingCapitalDiscountUpdated": "Der Rabatt für den Working-Capital-Kredit wurde erfolgreich aktualisiert.",
"unableToUpdateDiscount": "Rabatt konnte nicht aktualisiert werden."
},
"languages": {
"cs-CS": "Čeština (Tschechisch)",
Expand Down
Loading
Loading