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
18 changes: 10 additions & 8 deletions src/app/loans/common-resolvers/loan-action-button.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,23 @@ export class LoanActionButtonResolver {
} else if (loanActionButton === 'Make Repayment') {
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, 'repayment')
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, 'repayment');
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'repayment');
} else if (loanActionButton === 'Goodwill Credit') {
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, 'goodwillCredit')
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, 'goodwillCredit');
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'goodwillCredit');
} else if (loanActionButton === 'Interest Payment Waiver') {
return this.loansService.getLoanActionTemplate(loanId, 'interestPaymentWaiver');
} else if (loanActionButton === 'Payout Refund') {
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, 'payoutRefund')
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, 'repayment');
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'repayment');
} else if (loanActionButton === 'Merchant Issued Refund') {
return this.loansService.getLoanActionTemplate(loanId, 'merchantIssuedRefund');
} else if (loanActionButton === 'Credit Balance Refund') {
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, 'creditBalanceRefund')
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, 'creditBalanceRefund');
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'creditBalanceRefund');
} else if (loanActionButton === 'Waive Interest') {
return this.loansService.getLoanActionTemplate(loanId, 'waiveinterest');
} else if (loanActionButton === 'Write Off') {
Expand All @@ -72,23 +72,25 @@ export class LoanActionButtonResolver {
} else if (loanActionButton === 'Reschedule') {
return this.loansService.rescheduleLoanTemplate();
} else if (loanActionButton === 'Prepay Loan') {
return this.loansService.getLoanPrepayLoanActionTemplate(loanId, null);
return this.loanProductService.isLoanProduct
? this.loansService.getLoanPrepayLoanActionTemplate(loanId, null)
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'prepayLoan');
} else if (loanActionButton === 'Add Collateral') {
return this.loansService.getLoanCollateralTemplate(loanId);
} else if (loanActionButton === 'Disburse to Savings') {
return this.loansService.getLoanActionTemplate(loanId, 'disburseToSavings');
} else if (loanActionButton === 'Recovery Payment') {
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, 'recoverypayment')
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, 'recoveryPayment');
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'recoveryPayment');
} else if (loanActionButton === 'View Guarantors') {
return this.loansService.getGuarantors(loanId).pipe(catchError(() => of([])));
} else if (loanActionButton === 'Create Guarantor') {
return this.loansService.getGuarantorTemplate(loanId);
} else if (loanActionButton === 'Disburse') {
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, loanActionButton.toLowerCase())
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, loanActionButton.toLowerCase());
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'disburse');
} else if (loanActionButton === 'Loan Screen Reports') {
return this.loansService.getLoanScreenReportsData();
} else if (loanActionButton === 'Approve') {
Expand All @@ -102,7 +104,7 @@ export class LoanActionButtonResolver {
} else if (loanActionButton === 'Charge-Off') {
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, 'charge-off')
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, 'chargeOff');
: this.loansService.getWorkingCapitalLoanTransactionTemplate(loanId, 'chargeOff');
} else if (loanActionButton === 'Capitalized Income') {
return this.loansService.getLoanActionTemplate(loanId, 'capitalizedIncome');
} else if (loanActionButton === 'Contract Termination') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
<mifosx-foreclosure [dataObject]="actionButtonData"></mifosx-foreclosure>
}
@if (actions['Prepay Loan'] || actions['Contract Termination']) {
<mifosx-prepay-loan [dataObject]="actionButtonData"></mifosx-prepay-loan>
@if (isWorkingCapital && actions['Prepay Loan']) {
<mifosx-working-capital-prepay-loan [dataObject]="actionButtonData"></mifosx-working-capital-prepay-loan>
} @else {
<mifosx-prepay-loan [dataObject]="actionButtonData"></mifosx-prepay-loan>
}
}
@if (actions['Make Repayment']) {
<mifosx-make-repayment [dataObject]="actionButtonData"></mifosx-make-repayment>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { WorkingCapitalChargeOffComponent } from '../working-capital/loan-accoun
import { WorkingCapitalWriteOffComponent } from '../working-capital/loan-account-actions/write-off/write-off.component';
import { WorkingCapitalUndoWriteOffComponent } from '../working-capital/loan-account-actions/undo-write-off/undo-write-off.component';
import { WorkingCapitalRecoveryPaymentComponent } from '../working-capital/loan-account-actions/recovery-payment/recovery-payment.component';
import { WorkingCapitalPrepayLoanComponent } from '../working-capital/loan-account-actions/prepay-loan/prepay-loan.component';
import { LoanProductService } from 'app/products/loan-products/services/loan-product.service';

/**
Expand Down Expand Up @@ -98,7 +99,8 @@ import { LoanProductService } from 'app/products/loan-products/services/loan-pro
WorkingCapitalChargeOffComponent,
WorkingCapitalWriteOffComponent,
WorkingCapitalUndoWriteOffComponent,
WorkingCapitalRecoveryPaymentComponent
WorkingCapitalRecoveryPaymentComponent,
WorkingCapitalPrepayLoanComponent
],
changeDetection: ChangeDetectionStrategy.OnPush
})
Expand Down
7 changes: 7 additions & 0 deletions src/app/loans/loans-view/loan-accounts-button-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,13 @@ export class LoansAccountButtonConfiguration {
icon: 'coins',
taskPermissionName: 'REPAYMENT_WORKINGCAPITALLOAN'
},
// A prepayment is posted as a repayment for the full outstanding
// balance, so it is the repayment permission that gates it.
{
name: 'Prepay Loan',
icon: 'coins',
taskPermissionName: 'REPAYMENT_WORKINGCAPITALLOAN'
},
{
name: 'Payout Refund',
icon: 'coins',
Expand Down
77 changes: 74 additions & 3 deletions src/app/loans/loans-view/loans-view.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,19 @@ describe('LoansViewComponent', () => {
let loansServiceStub: any;
let systemServiceStub: any;

function createComponent(): LoansViewComponent {
/**
* Builds the component straight from the injection context, without rendering its template: the action menu is
* assembled in the constructor, so consuming the seeded route data is enough to assert on it.
*
* The TestBed is reset on every call because several tests build a component per product type or per flag value.
* @param productType Which product the LoanProductService stub reports; defaults to a plain loan product.
*/
function createComponent(
productType: { isLoanProduct: boolean; isWorkingCapital: boolean } = {
isLoanProduct: true,
isWorkingCapital: false
}
): LoansViewComponent {
const routeStub = {
data: routeData$,
params: new BehaviorSubject({ loanId: '1' }),
Expand All @@ -42,6 +54,7 @@ describe('LoansViewComponent', () => {
}
};

TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
Expand All @@ -50,8 +63,8 @@ describe('LoansViewComponent', () => {
{
provide: LoanProductService,
useValue: {
isLoanProduct: true,
isWorkingCapital: false,
isLoanProduct: productType.isLoanProduct,
isWorkingCapital: productType.isWorkingCapital,
initialize: jest.fn()
}
},
Expand All @@ -66,6 +79,29 @@ describe('LoansViewComponent', () => {
return TestBed.runInInjectionContext(() => new LoansViewComponent());
}

/**
* Seeds the resolved route data with an active loan, which is the only status
* whose action menu carries Prepay Loan and Add Interest Pause.
*/
function seedActiveLoan(overrides: Record<string, unknown> = {}): void {
routeData$.next({
loanDetailsData: {
...loanDetailsData,
status: { active: true, value: 'Active' },
...overrides
}
});
}

/**
* Flattens the assembled action menu to the button names, which is all these tests assert on.
* @param component The component whose button configuration to read.
* @returns The action names in menu order, or an empty list when no menu was built.
*/
function buttonNames(component: LoansViewComponent): string[] {
return (component.buttonConfig?.singleButtons ?? []).map((button: { name: string }) => button.name);
}

beforeEach(() => {
routeData$ = new BehaviorSubject({ loanDetailsData });
datatables$ = new Subject<any[]>();
Expand Down Expand Up @@ -103,4 +139,39 @@ describe('LoansViewComponent', () => {
expect(component.loanDatatables).toEqual(datatables);
expect(component.datatablesReady).toBe(true);
});

describe('action menu on an active loan', () => {
it('offers Prepay Loan whether or not the product recalculates interest', () => {
seedActiveLoan({ isInterestRecalculationEnabled: false });
expect(buttonNames(createComponent())).toContain('Prepay Loan');

seedActiveLoan({ isInterestRecalculationEnabled: true });
expect(buttonNames(createComponent())).toContain('Prepay Loan');
});

it('offers Add Interest Pause only when the loan recalculates interest', () => {
seedActiveLoan({ isInterestRecalculationEnabled: true });
expect(buttonNames(createComponent())).toContain('Add Interest Pause');

seedActiveLoan({ isInterestRecalculationEnabled: false });
expect(buttonNames(createComponent())).not.toContain('Add Interest Pause');
});

it('withholds Add Interest Pause when the loan details omit the interest recalculation flag', () => {
seedActiveLoan();

expect(buttonNames(createComponent())).not.toContain('Add Interest Pause');
});

it('leaves the Working Capital action menu to its own configuration', () => {
seedActiveLoan({ isInterestRecalculationEnabled: false });

const names = buttonNames(createComponent({ isLoanProduct: false, isWorkingCapital: true }));

// Working Capital declares Prepay Loan itself, so it appears exactly once
// and never picks up the loan-product entry or Add Interest Pause.
expect(names.filter((name) => name === 'Prepay Loan')).toHaveLength(1);
expect(names).not.toContain('Add Interest Pause');
});
});
});
13 changes: 8 additions & 5 deletions src/app/loans/loans-view/loans-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,6 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
loanDatatables: any[] = [];
/** Whether datatable filtering has completed */
datatablesReady = false;
/** Recalculate Interest */
recalculateInterest: any;
/** loan Arrears Delinquency config value */
loanDisplayArrearsDelinquency = 0;
/** Status */
Expand Down Expand Up @@ -214,7 +212,6 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
this.reload();
}
});
this.recalculateInterest = this.loanDetailsData?.recalculateInterest || true;
this.status = this.loanDetailsData?.status?.value;
this.loanStatus = this.loanDetailsData?.status;
this.loanSubStatus = this.loanDetailsData?.subStatus === undefined ? null : this.loanDetailsData?.subStatus;
Expand Down Expand Up @@ -387,7 +384,10 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
taskPermissionName: 'DISBURSALLASTUNDO_LOAN'
});
}
if (this.loanProductService.isLoanProduct && this.recalculateInterest) {
// An interest pause suspends interest accrual, which the backend only
// accepts on a loan that recalculates interest: without it the request is
// rejected with loan.must.have.recalculate.interest.enabled.
if (this.loanProductService.isLoanProduct && this.loanDetailsData.isInterestRecalculationEnabled) {
this.buttonConfig.addButton({
name: 'Add Interest Pause',
icon: 'calendar',
Expand All @@ -404,7 +404,10 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
});
}

if (this.recalculateInterest) {
// Any active loan with a balance can be paid off, so this is unconditional.
// Working Capital declares its own Prepay Loan entry in the button
// configuration, with the Working Capital repayment permission.
if (this.loanProductService.isLoanProduct) {
this.buttonConfig.addButton({
name: 'Prepay Loan',
icon: 'coins',
Expand Down
Loading
Loading