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
48 changes: 48 additions & 0 deletions src/app/features/loans/loan-view.component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,54 @@ describe('LoanViewComponent', () => {
});
});

/**
* `error.msg.loan.must.be.active.fully.paid.or.overpaid` — the platform takes a repayment in
* exactly three states. The button sat outside the state checks that already governed Approve
* and Disburse beside it, so a loan awaiting approval offered a repayment form that could only
* be rejected on submit.
*/
describe('the repayment button by loan state', () => {
function repaymentButton(): Element | null {
return fixture.nativeElement.querySelector('[data-testid="loan-repayment-action"]');
}

it('is offered on an active loan', async () => {
await setup({ status: { value: 'Active', active: true } });

expect(component.canAcceptRepayment).toBe(true);
expect(repaymentButton()).not.toBeNull();
});

it('is offered on an overpaid loan and on one whose obligations are met', async () => {
await setup({ status: { value: 'Overpaid', overpaid: true } });
expect(component.canAcceptRepayment).toBe(true);

await setup({ status: { value: 'Closed (obligations met)', closedObligationsMet: true } });
expect(component.canAcceptRepayment).toBe(true);
});

it('is withheld from a loan awaiting approval', async () => {
await setup({ status: { value: 'Submitted and pending approval', pendingApproval: true } });

expect(component.canAcceptRepayment).toBe(false);
expect(repaymentButton()).toBeNull();
});

it('is withheld from an approved loan that has not been disbursed', async () => {
await setup({ status: { value: 'Approved', waitingForDisbursal: true } });

expect(component.canAcceptRepayment).toBe(false);
expect(repaymentButton()).toBeNull();
});

it('is withheld from a written-off loan', async () => {
await setup({ status: { value: 'Closed (written off)', closedWrittenOff: true } });

expect(component.canAcceptRepayment).toBe(false);
expect(repaymentButton()).toBeNull();
});
});

/**
* `PUT .../approved-amount` and `PUT .../available-disbursement-amount` — #284. Both are their
* own sub-resources, not `postLoansLoanId` commands, and both are legal on an approved loan and
Expand Down
41 changes: 31 additions & 10 deletions src/app/features/loans/loan-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,16 +238,24 @@ export function toEditableDate(value: unknown): string {
</div>
</div>
<div class="actions-area">
<ion-button
color="primary"
data-testid="loan-repayment-action"
appRequiresPermission="REPAYMENT_LOAN"
(click)="onRepayment()"
[appTooltip]="'LOANS.REPAYMENT' | translate"
>
<ion-icon name="card-outline"></ion-icon>
{{ 'LOANS.REPAYMENT' | translate }}
</ion-button>
<!--
Hidden rather than disabled, like the Approve and Disburse buttons beside it: the
platform refuses a repayment unless the loan is active, fully paid or overpaid
(error.msg.loan.must.be.active.fully.paid.or.overpaid), so on a loan awaiting
approval the button only led to a filled-in form and a rejection on submit.
-->
@if (canAcceptRepayment) {
<ion-button
color="primary"
data-testid="loan-repayment-action"
appRequiresPermission="REPAYMENT_LOAN"
(click)="onRepayment()"
[appTooltip]="'LOANS.REPAYMENT' | translate"
>
<ion-icon name="card-outline"></ion-icon>
{{ 'LOANS.REPAYMENT' | translate }}
</ion-button>
}

@if (isLoanPendingApproval) {
<ion-button
Expand Down Expand Up @@ -1882,6 +1890,19 @@ export class LoanViewComponent implements OnInit {
return !!this.loan()?.status?.active;
}

/**
* Whether the platform will accept a repayment on this loan.
*
* `LoanRepaymentValidator` refuses anything else with
* `error.msg.loan.must.be.active.fully.paid.or.overpaid`, so these three flags are the whole
* rule — a loan awaiting approval, awaiting disbursal, rejected, withdrawn or written off
* cannot take one.
*/
get canAcceptRepayment(): boolean {
const status = this.loan()?.status;
return !!(status?.active || status?.overpaid || status?.closedObligationsMet);
}

/**
* Whether the approved and available-disbursement amounts are meaningful to revise — #284.
*
Expand Down
42 changes: 42 additions & 0 deletions src/app/features/products/savings-account-view.component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,46 @@ describe('SavingsAccountViewComponent', () => {
expect(savingsServiceSpy.postSavingsaccountsAccountId).not.toHaveBeenCalled();
});
});
/**
* The platform refuses a deposit or a withdrawal unless the account is active —
* `error.msg.savingsaccount.transaction.account.is.not.active`. Offering the buttons on an
* account that is still awaiting approval led to a filled-in transaction form and a rejection
* on submit, so they are withheld in exactly the states the platform rejects.
*/
describe('transaction buttons', () => {
function withStatus(status: Record<string, unknown>): void {
component.account.set({ ...component.account(), status } as never);
fixture.detectChanges();
}

const TEST_ID = {
deposit: '[data-testid="savings-deposit-action"]',
withdraw: '[data-testid="savings-withdraw-action"]',
} as const;

function buttonFor(action: keyof typeof TEST_ID): Element | null {
return fixture.nativeElement.querySelector(TEST_ID[action]);
}

it('offers deposit and withdrawal on an active account', () => {
withStatus({ value: 'Active', active: true });

expect(buttonFor('deposit')).not.toBeNull();
expect(buttonFor('withdraw')).not.toBeNull();
});

it('withholds both from an account awaiting approval', () => {
withStatus({ value: 'Submitted and pending approval', submittedAndPendingApproval: true });

expect(buttonFor('deposit')).toBeNull();
expect(buttonFor('withdraw')).toBeNull();
});

it('withholds both from a closed account', () => {
withStatus({ value: 'Closed', closed: true });

expect(buttonFor('deposit')).toBeNull();
expect(buttonFor('withdraw')).toBeNull();
});
});
});
45 changes: 27 additions & 18 deletions src/app/features/products/savings-account-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,25 +220,34 @@ export type SavingsTab = (typeof SAVINGS_TAB)[keyof typeof SAVINGS_TAB];
{{ 'SAVINGS.CLOSE' | appTranslate }}
</ion-button>
}
<ion-button
color="primary"
appRequiresPermission="DEPOSIT_SAVINGSACCOUNT"
(click)="onTransaction('deposit')"
[appTooltip]="'SAVINGS.DEPOSIT_CASH' | appTranslate"
>
<ion-icon name="add-circle-outline"></ion-icon>
{{ 'SAVINGS.DEPOSIT' | appTranslate }}
</ion-button>
<ion-button
color="danger"
appRequiresPermission="WITHDRAW_SAVINGSACCOUNT"
(click)="onTransaction('withdrawal')"
[appTooltip]="'SAVINGS.WITHDRAW_CASH' | appTranslate"
>
<ion-icon name="remove-circle-outline"></ion-icon>
{{ 'SAVINGS.WITHDRAW' | appTranslate }}
</ion-button>
<!--
Behind the same status check as Close and the Actions menu below. The platform
refuses a deposit or a withdrawal on anything but an active account
(error.msg.savingsaccount.transaction.account.is.not.active), so on an account
still awaiting approval these two offered a transaction form that could only end
in a rejection.
-->
@if (isActive()) {
<ion-button
color="primary"
data-testid="savings-deposit-action"
appRequiresPermission="DEPOSIT_SAVINGSACCOUNT"
(click)="onTransaction('deposit')"
[appTooltip]="'SAVINGS.DEPOSIT_CASH' | appTranslate"
>
<ion-icon name="add-circle-outline"></ion-icon>
{{ 'SAVINGS.DEPOSIT' | appTranslate }}
</ion-button>
<ion-button
color="danger"
data-testid="savings-withdraw-action"
appRequiresPermission="WITHDRAW_SAVINGSACCOUNT"
(click)="onTransaction('withdrawal')"
[appTooltip]="'SAVINGS.WITHDRAW_CASH' | appTranslate"
>
<ion-icon name="remove-circle-outline"></ion-icon>
{{ 'SAVINGS.WITHDRAW' | appTranslate }}
</ion-button>
<ion-button color="primary" id="savingsMenu-trigger" data-testid="savings-actions">
<ion-icon name="caret-down-outline"></ion-icon>
{{ 'COMMON.ACTIONS' | appTranslate }}
Expand Down
Loading