diff --git a/e2e/loan-optional-repayment-date.spec.ts b/e2e/loan-optional-repayment-date.spec.ts index 011b3b017..9600c312d 100644 --- a/e2e/loan-optional-repayment-date.spec.ts +++ b/e2e/loan-optional-repayment-date.spec.ts @@ -53,6 +53,11 @@ async function signIn(page: Page): Promise { }); }); + // 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'); @@ -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) => { diff --git a/src/app/features/loans/loan-form.component.test.ts b/src/app/features/loans/loan-form.component.test.ts index eac5b6fe2..8128b2c30 100644 --- a/src/app/features/loans/loan-form.component.test.ts +++ b/src/app/features/loans/loan-form.component.test.ts @@ -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'; @@ -33,8 +33,10 @@ describe('LoanFormComponent optional repayment date', () => { let fixture: ComponentFixture; let component: LoanFormComponent; let http: HttpTestingController; + let routeParams: BehaviorSubject; beforeEach(async () => { + routeParams = new BehaviorSubject(convertToParamMap({})); await TestBed.configureTestingModule({ imports: [LoanFormComponent], providers: [ @@ -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(); @@ -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(); diff --git a/src/app/features/loans/loan-form.component.ts b/src/app/features/loans/loan-form.component.ts index 435bf06ec..fd250114e 100644 --- a/src/app/features/loans/loan-form.component.ts +++ b/src/app/features/loans/loan-form.component.ts @@ -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.'; @@ -489,7 +495,7 @@ const OPERATION_FAILED_MESSAGE = 'Operation failed. Please try again.'; }} @if (pickersReady()) { - @if (!isEditMode() && !repaymentsStartingFromDate) { + @if (!repaymentsStartingFromDate && !storedRepaymentStartDate()) { {{ 'LOANS.REPAYMENT_START_DATE_NOT_SET' | appTranslate }} @@ -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" > @@ -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(null); readonly products = signal([]); readonly selectedProductDetails = signal(null); @@ -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));