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
91 changes: 91 additions & 0 deletions e2e/loan-optional-repayment-date.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ async function signIn(page: Page): Promise<void> {
});
});

// Dashboard summaries are outside this journey; keep every request synthetic.
await page.route(/\/api\/v1\/(clients|loans|savingsaccounts)\?/, (route) =>
route.fulfill({ json: { pageItems: [], totalFilteredRecords: 0 } }),
);

await page.goto('/login');
await page.locator('#tenantId').fill('default');
await page.locator('#username').fill('mifos');
Expand Down Expand Up @@ -102,6 +107,92 @@ for (const viewport of [
]) {
test.describe(`optional repayment date at ${viewport.width}px`, () => {
test.use({ viewport });
test('edit dates reflect the stored value without silently submitting it', async ({
page,
}, testInfo) => {
await signIn(page);
await page.route('**/api/v1/clients/7**', (route) =>
route.fulfill({ json: { id: 7, displayName: 'Synthetic Client' } }),
);
await page.route('**/api/v1/loanproducts**', (route) =>
route.fulfill({
json: /\/loanproducts\/3(?:\?|$)/.test(route.request().url()) ? product : [product],
}),
);
let storedDate: number[] | null = [2026, 4, 30];
await page.route('**/api/v1/loans/7**', (route) =>
route.fulfill({
json:
route.request().method() === 'PUT'
? { loanId: 7 }
: {
...product,
id: 7,
accountNo: 'SYNTHETIC7',
clientId: 7,
clientName: 'Synthetic Client',
loanProductId: 3,
termFrequency: 12,
termPeriodFrequencyType: { id: 2 },
status: { id: 100, value: 'Submitted' },
timeline: {
submittedOnDate: [2026, 4, 1],
expectedDisbursementDate: [2026, 4, 2],
},
expectedFirstRepaymentOnDate: storedDate,
summary: {},
},
}),
);
const saveEdit = async () => {
const update = page.waitForRequest(
(request) => request.method() === 'PUT' && /\/loans\/7(?:\?|$)/.test(request.url()),
);
await page.getByRole('button', { name: 'Save', exact: true }).click();
const body = (await update).postDataJSON();
await expect(page).toHaveURL(/\/loans\/view\/7$/);
return body;
};

await page.goto('/loans/edit/7');
await expect(dateButton(page).getByRole('button')).toContainText('2026');
await dateButton(page).click();
const picker = page.getByTestId(pickerId);
await expect(picker.getByRole('button', { name: /Thursday, April 30/ })).toHaveAttribute(
'aria-selected',
'true',
);
await page.keyboard.press('Escape');
await expect(picker).not.toBeVisible();
await page
.locator('ion-item')
.filter({ has: dateButton(page) })
.screenshot({
path: testInfo.outputPath('stored-edit-repayment-date.png'),
});
expect(await saveEdit()).not.toHaveProperty('repaymentsStartingFromDate');

await page.goto('/loans/edit/7');
await dateButton(page).click();
await picker.getByRole('button', { name: /Wednesday, April 29/ }).click();
await page.keyboard.press('Escape');
await expect(picker).not.toBeVisible();
expect(await saveEdit()).toHaveProperty('repaymentsStartingFromDate', '29 April 2026');

storedDate = null;
await page.goto('/loans/edit/7');
await expect(
dateButton(page).getByRole('button', { name: 'Not set', exact: true }),
).toBeVisible();
await expect(page.getByTestId('clear-repayment-start-date')).toHaveCount(0);
await page
.locator('ion-item')
.filter({ has: dateButton(page) })
.screenshot({
path: testInfo.outputPath('unset-edit-repayment-date.png'),
});
});

test('unset, selected and cleared create dates match their submitted fields', async ({
page,
}, testInfo) => {
Expand Down
82 changes: 79 additions & 3 deletions src/app/features/loans/loan-form.component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { NgModel } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { of } from 'rxjs';
import { ActivatedRoute, Router, convertToParamMap, type ParamMap } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { provideFakeAdapters } from '../../testing/adapters';
import { provideTranslateTesting } from '../../testing/i18n-testing';
import { provideIonicTesting } from '../../testing/ionic-testing';
Expand All @@ -33,8 +33,10 @@ describe('LoanFormComponent optional repayment date', () => {
let fixture: ComponentFixture<LoanFormComponent>;
let component: LoanFormComponent;
let http: HttpTestingController;
let routeParams: BehaviorSubject<ParamMap>;

beforeEach(async () => {
routeParams = new BehaviorSubject(convertToParamMap({}));
await TestBed.configureTestingModule({
imports: [LoanFormComponent],
providers: [
Expand All @@ -46,7 +48,7 @@ describe('LoanFormComponent optional repayment date', () => {
{ provide: Router, useValue: { navigate: vi.fn() } },
{
provide: ActivatedRoute,
useValue: { queryParams: of({}), paramMap: of({ get: () => null }) },
useValue: { queryParams: of({}), paramMap: routeParams },
},
],
}).compileComponents();
Expand Down Expand Up @@ -121,6 +123,80 @@ describe('LoanFormComponent optional repayment date', () => {
expect(retry.request.body).not.toHaveProperty('repaymentsStartingFromDate');
retry.flush({ loanId: 7 });
});
async function loadEditLoan(date: string | number[] | null | undefined) {
routeParams.next(convertToParamMap({ id: '7' }));
http
.expectOne((request) => request.url.endsWith('/loans/7'))
.flush({
id: 7,
clientId: 7,
principal: 1000,
timeline: { submittedOnDate: [2026, 4, 1], expectedDisbursementDate: [2026, 4, 2] },
...(date === undefined ? {} : { expectedFirstRepaymentOnDate: date }),
});
await fixture.whenStable();
fixture.detectChanges();
http
.expectOne((request) => request.url.endsWith('/clients/7'))
.flush({
id: 7,
displayName: 'Synthetic Client',
});
await fixture.whenStable();
fixture.detectChanges();
}

for (const { name, date } of [
{ name: 'absent', date: undefined },
{ name: 'null', date: null },
]) {
it(`shows the unset placeholder for an ${name} saved repayment date`, async () => {
await loadEditLoan(date);
const button = fixture.nativeElement.querySelector(
'ion-datetime-button[datetime="repaymentsStartingFromDate-picker"]',
);
expect(button.querySelector('[slot="date-target"]')?.textContent ?? '').toContain(
'LOANS.REPAYMENT_START_DATE_NOT_SET',
);
expect(component.repaymentsStartingFromDate).toBeNull();
});
}

for (const { name, date } of [
{ name: 'array', date: [2026, 4, 30] },
{ name: 'string', date: '2026-04-30' },
]) {
it(`displays a saved ${name} date without submitting a replacement on unchanged save`, async () => {
await loadEditLoan(date);
const control = fixture.debugElement
.query(By.css('ion-datetime[name="repaymentsStartingFromDate"]'))
.injector.get(NgModel);
expect(control.control.value).toBe('2026-04-30');
expect(component.repaymentsStartingFromDate).toBeNull();
const button = fixture.nativeElement.querySelector(
'ion-datetime-button[datetime="repaymentsStartingFromDate-picker"]',
);
expect(button.querySelector('[slot="date-target"]')).toBeNull();
component.onSubmit();
const update = http.expectOne(
(request) => request.method === 'PUT' && request.url.endsWith('/loans/7'),
);
expect(update.request.body).not.toHaveProperty('repaymentsStartingFromDate');
update.flush({ loanId: 7 });
});
}

it('submits an officer-selected replacement through the existing date conversion', async () => {
await loadEditLoan([2026, 4, 30]);
await chooseDate();
component.onSubmit();
const update = http.expectOne(
(request) => request.method === 'PUT' && request.url.endsWith('/loans/7'),
);
expect(update.request.body).toHaveProperty('repaymentsStartingFromDate', '05 November 2026');
update.flush({ loanId: 7 });
});

it('does not introduce a clearing operation for an existing loan', async () => {
component.isEditMode.set(true);
await chooseDate();
Expand Down
17 changes: 14 additions & 3 deletions src/app/features/loans/loan-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ import {
FINERACT_LOCALE,
} from '../../core/utils/date-formatter';
import { createPickersReady } from '../../shared/utils/pickers-ready';
import { toIsoFineractDate } from '../../core/adapters/api/fineract-date';

// The loan read API exposes this date, but the generated response model omits it.
type LoanResponseWithRepaymentDate = GetLoansLoanIdResponse & {
expectedFirstRepaymentOnDate?: string | number[] | null;
};

const OPERATION_FAILED_MESSAGE = 'Operation failed. Please try again.';

Expand Down Expand Up @@ -489,7 +495,7 @@ const OPERATION_FAILED_MESSAGE = 'Operation failed. Please try again.';
}}</ion-label>
@if (pickersReady()) {
<ion-datetime-button datetime="repaymentsStartingFromDate-picker">
@if (!isEditMode() && !repaymentsStartingFromDate) {
@if (!repaymentsStartingFromDate && !storedRepaymentStartDate()) {
<span slot="date-target">{{
'LOANS.REPAYMENT_START_DATE_NOT_SET' | appTranslate
}}</span>
Expand All @@ -503,7 +509,8 @@ const OPERATION_FAILED_MESSAGE = 'Operation failed. Please try again.';
data-testid="repaymentsStartingFromDate-picker"
presentation="date"
name="repaymentsStartingFromDate"
[(ngModel)]="repaymentsStartingFromDate"
[ngModel]="repaymentsStartingFromDate ?? storedRepaymentStartDate()"
(ngModelChange)="repaymentsStartingFromDate = $event"
></ion-datetime>
</ng-template>
</ion-modal>
Expand Down Expand Up @@ -628,6 +635,7 @@ export class LoanFormComponent implements OnInit {
readonly submittedOnDate = signal(this.platformDate.today());
readonly expectedDisbursementDate = signal(this.platformDate.today());
repaymentsStartingFromDate: string | null = null;
readonly storedRepaymentStartDate = signal<string | null>(null);
readonly products = signal<GetLoanProductsResponse[]>([]);
readonly selectedProductDetails = signal<GetLoanProductsProductIdResponse | null>(null);

Expand Down Expand Up @@ -756,7 +764,10 @@ export class LoanFormComponent implements OnInit {
private loadLoanData() {
if (!this.loanId) return;
this.loansService.getLoansLoanId(this.loanId).subscribe({
next: (data: GetLoansLoanIdResponse) => {
next: (data: LoanResponseWithRepaymentDate) => {
// A loaded value is display state, not an officer-selected replacement.
this.repaymentsStartingFromDate = null;
this.storedRepaymentStartDate.set(toIsoFineractDate(data.expectedFirstRepaymentOnDate));
const subDateArray = data.timeline?.submittedOnDate as unknown as number[];
if (subDateArray) {
this.submittedOnDate.set(formatArrayDate(subDateArray));
Expand Down
Loading