diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index dbe80f692..5ffe313b8 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -70,10 +70,29 @@ const IONIC_SELECT_ARIA_REQUIRED = [ 'aria-allowed-attr|ion-select[name="officeId"] >> #ion-sel-*', 'aria-allowed-attr|#office-parent >> #ion-sel-*', ]; +/** + * The office select's own placeholder text, which has always failed AA and was never evaluated. + * + * Ionic renders `.select-placeholder` as `color: rgb(51, 51, 51)` at `opacity: 0.6`, which lands + * at #858585 on white — 3.69:1 against the 4.5:1 this scan asks for. Measured identically on + * `main`, so the colour is not new. + * + * What is new is that axe can see it. Until now this page carried an error toast at scan time + * (one unmocked request is enough), and an overlay puts `color-contrast` into axe's *incomplete* + * bucket for every node it covers — fourteen of them here — so the rule was never decided either + * way. The business-date reads behind the header no longer raise that toast, so the page is + * finally scannable and says what it had been hiding. `expectScanWasNotBlind` does not catch + * this case: other rules still passed, so the scan was not blind, only this rule was. + * + * Baselined rather than fixed because the fix is a theme change — Ionic's placeholder opacity, + * applied to every select and input in the application — which belongs with #484's contrast work + * rather than in a form-submission change. + */ const CLIENT_FORM_BASELINE = new Set([ ...IONIC_SELECT_ARIA_REQUIRED, 'role-img-alt|ion-icon[name="add-circle-outline"]', 'role-img-alt|.help-icon[name="help-circle-outline"]', + 'color-contrast|.has-placeholder >> .select-placeholder', ]); const CREATE_OFFICE_DIALOG_BASELINE = new Set(IONIC_SELECT_ARIA_REQUIRED); /** diff --git a/e2e/double-submit-backend.spec.ts b/e2e/double-submit-backend.spec.ts new file mode 100644 index 000000000..449cf0665 --- /dev/null +++ b/e2e/double-submit-backend.spec.ts @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * #756. One double-click posted two repayments. + * + * Measured on a running Fineract: `POST /loans/{id}/transactions?command=repayment` twice, both + * `200`, two transactions of the same amount, and the borrower's balance reduced by double what + * they paid. Create Client made two clients the same way. The forms' `isSaving` flag is bound to + * `[disabled]`, which the second click of a double-click beats to the DOM. + * + * These run against the real platform because the assertion that matters is not "one request + * left the browser" but "the money moved once" — the first is how the UI behaves, the second is + * what the borrower's statement says. + */ + +import { test, expect, Page, recordingTimeout } from './fixtures'; +import { login } from './utils/fineract-login'; +import { selectOption } from './utils/select-option'; +import { + API_BASE, + createApiContext, + loanTransactions, + seedActiveLoan, + seedSuffix, +} from './utils/seed-api'; + +/** Counts the requests the page issues to a command endpoint while a block runs. */ +async function countingPosts( + page: Page, + pattern: RegExp, + action: () => Promise, +): Promise { + const seen: string[] = []; + const listener = (request: { method: () => string; url: () => string }): void => { + if (request.method() === 'POST' && pattern.test(request.url())) seen.push(request.url()); + }; + page.on('request', listener); + try { + await action(); + } finally { + page.off('request', listener); + } + return seen.length; +} + +test.describe('A double-click submits once', () => { + test('a repayment is recorded once, not twice', async ({ page }) => { + test.setTimeout(recordingTimeout(240000)); + const api = await createApiContext(); + const loan = await seedActiveLoan(api, 'E2EDouble'); + + await login(page); + await page.goto(`/loans/${loan.loanId}/transactions/repayment`); + + // The transaction date prefills from the loan template, and renders behind an + // ion-datetime-button with no native input — only the amount needs setting. + const amount = page.locator('input[name="transactionAmount"]'); + await expect(amount).toBeVisible({ timeout: 20000 }); + await amount.fill('100'); + + const posts = await countingPosts( + page, + /\/loans\/\d+\/transactions\?command=repayment/, + async () => { + // One double-click, as a cashier makes it — not two deliberate submissions. + await page.getByRole('button', { name: /^save$/i }).dblclick(); + // A save returns to the record it affected (#798), so this is the loan, not the list. + await expect(page).toHaveURL(new RegExp(`/loans/view/${loan.loanId}$`), { + timeout: 20000, + }); + }, + ); + + expect(posts).toBe(1); + + // And the platform agrees: one repayment of 100, not two. + const transactions = await loanTransactions(api, loan.loanId); + const hundreds = transactions.filter( + (transaction) => transaction.amount === 100 && transaction.manuallyReversed !== true, + ); + expect(hundreds).toHaveLength(1); + }); + + test('a client is created once, not twice', async ({ page }) => { + test.setTimeout(recordingTimeout(240000)); + const api = await createApiContext(); + const surname = `Double${seedSuffix()}`; + + await login(page); + // The form is a three-step wizard: office, then the name, then review. + await page.goto('/clients/create'); + await selectOption(page, 'Office', 'Head Office'); + await page.getByRole('button', { name: 'Next' }).click(); + await page.getByRole('textbox', { name: 'First Name' }).fill('E2EDbl'); + await page.getByRole('textbox', { name: 'Last Name' }).fill(surname); + await page.getByRole('button', { name: 'Next' }).click(); + + const posts = await countingPosts(page, /\/clients(\?|$)/, async () => { + await page.getByRole('button', { name: /^save$/i }).dblclick(); + // The created client's own page (#798). A form that never submitted stays on + // `/clients/create`, which this does not match — the id segment is what rules that out. + await expect(page).toHaveURL(/\/clients\/view\/\d+$/, { timeout: 20000 }); + }); + + expect(posts).toBe(1); + + // Duplicate clients break KYC uniqueness, so the platform side is asserted too. + const response = await api.get(`${API_BASE}/clients?displayName=${surname}&limit=50`); + const body = (await response.json()) as { pageItems?: { displayName?: string }[] }; + const matches = (body.pageItems ?? []).filter((client) => + (client.displayName ?? '').includes(surname), + ); + expect(matches).toHaveLength(1); + }); +}); diff --git a/e2e/e2e-journey.spec.ts b/e2e/e2e-journey.spec.ts index 64feacdd6..74112b2ec 100644 --- a/e2e/e2e-journey.spec.ts +++ b/e2e/e2e-journey.spec.ts @@ -134,9 +134,14 @@ async function mockDashboardCounts(page: Page) { }); } -// The header leaves out the business date when the instance has none (GET /businessdate -// answers []), so the header checks below need one to be configured. +// The header leaves out the business date when none *governs*: the app asks +// `GET /configurations/name/enable-business-date` before trusting `GET /businessdate`, because +// those rows persist after the feature is switched off and stop being maintained (#758). Both +// have to be answered for the header checks below. async function mockBusinessDate(page: Page) { + await page.route('**/api/v1/configurations/name/enable-business-date**', async (route) => { + await route.fulfill({ json: { name: 'enable-business-date', enabled: true } }); + }); await page.route('**/api/v1/businessdate**', async (route) => { await route.fulfill({ json: [{ type: 'BUSINESS_DATE', date: [2026, 10, 2] }] }); }); diff --git a/e2e/feature-coverage.spec.ts b/e2e/feature-coverage.spec.ts index 7a2ceecbf..42cf7c027 100644 --- a/e2e/feature-coverage.spec.ts +++ b/e2e/feature-coverage.spec.ts @@ -74,8 +74,12 @@ async function loginAndMockApi(page: Page) { }); }); - // The header leaves out the business date when the instance has none, and - // 'header shows logged-in user info' checks for it. + // The header leaves out the business date when none *governs*, and 'header shows logged-in + // user info' checks for it. The app asks whether the feature is enabled before trusting the + // stored rows, which persist after it is switched off (#758), so both are answered here. + await page.route('**/api/v1/configurations/name/enable-business-date**', async (route) => { + await route.fulfill({ json: { name: 'enable-business-date', enabled: true } }); + }); await page.route('**/api/v1/businessdate**', async (route) => { await route.fulfill({ json: [{ type: 'BUSINESS_DATE', date: [2026, 10, 2] }] }); }); diff --git a/e2e/loan-delinquency-actions.spec.ts b/e2e/loan-delinquency-actions.spec.ts index 5b286dbe0..f104256ca 100644 --- a/e2e/loan-delinquency-actions.spec.ts +++ b/e2e/loan-delinquency-actions.spec.ts @@ -135,6 +135,14 @@ async function signInWithLoan(page: Page, setup: Setup = {}): Promise { }), ), ); + // A tenant *using* the business date, which is what these cases are about: the app asks + // whether the feature is enabled before trusting the stored rows, because they persist after + // it is switched off and stop being maintained (#758). Without the first mock this tab would + // fall back to the platform's clock, which is the right answer for a tenant without the + // feature and the wrong one for these assertions. + await page.route('**/api/v1/configurations/name/enable-business-date**', (route) => + route.fulfill(json({ name: 'enable-business-date', enabled: true })), + ); await page.route('**/api/v1/businessdate**', (route) => route.fulfill(json([{ type: 'BUSINESS_DATE', date: BUSINESS_DATE }])), ); diff --git a/e2e/tenant-date-defaults-backend.spec.ts b/e2e/tenant-date-defaults-backend.spec.ts new file mode 100644 index 000000000..7512d13a9 --- /dev/null +++ b/e2e/tenant-date-defaults-backend.spec.ts @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * #758. Create Client and Create Loan defaulted their dates to the browser's day. + * + * Fineract decides what "the future" is by its own reckoning — the tenant's timezone, or the + * business date when that feature is on — so whenever the browser runs ahead, submitting the + * defaults was refused: `error.msg.clients.submittedOnDate.in.the.future` on the client form, + * and a `403` reading like a permission problem on the loan one. Every client and loan entered + * during those hours failed on first submit, and the user was pushed into editing dates by + * trial and error. + * + * The browser clock is moved a day forward here rather than waited for, with Playwright's clock + * API. `setFixedTime` is deliberate: it changes what `new Date()` answers without replacing the + * timer queue, which Angular and Ionic both run on. + */ + +import { test, expect, recordingTimeout } from './fixtures'; +import { login } from './utils/fineract-login'; +import { selectOption } from './utils/select-option'; +import { API_BASE, createApiContext, seedSuffix } from './utils/seed-api'; + +/** A day after the real one, which is what the defect needs to show itself. */ +function tomorrow(): Date { + const date = new Date(); + date.setDate(date.getDate() + 1); + return date; +} + +test.describe('Date defaults come from the platform, not the browser', () => { + test('a client can be created while the browser is a day ahead', async ({ page }) => { + test.setTimeout(recordingTimeout(240000)); + // Fixed before the app loads, so every `new Date()` in it reads the later day. + await page.clock.setFixedTime(tomorrow()); + + await login(page); + const surname = `AheadClient${seedSuffix()}`; + + await page.goto('/clients/create'); + await selectOption(page, 'Office', 'Head Office'); + await page.getByRole('button', { name: 'Next' }).click(); + await page.getByRole('textbox', { name: 'First Name' }).fill('E2EAhead'); + await page.getByRole('textbox', { name: 'Last Name' }).fill(surname); + await page.getByRole('button', { name: 'Next' }).click(); + + // No date is touched. Before the fix this submitted the browser's tomorrow and the platform + // refused it; the form stayed put with a toast naming an ISO date. + await page.getByRole('button', { name: /^save$/i }).click(); + // The created client's own page (#798). A refused save stays on `/clients/create`, which + // this does not match — the id segment is what rules that out. + await expect(page).toHaveURL(/\/clients\/view\/\d+$/, { timeout: 20000 }); + + // The platform holding the record is the assertion — a navigation alone could mean a toast + // was missed. + const api = await createApiContext(); + const response = await api.get(`${API_BASE}/clients?displayName=${surname}&limit=50`); + const body = (await response.json()) as { pageItems?: { displayName?: string }[] }; + expect( + (body.pageItems ?? []).filter((client) => (client.displayName ?? '').includes(surname)), + ).toHaveLength(1); + }); +}); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 91969060d..684144b9f 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2714,11 +2714,6 @@ "count": 1 } }, - "src/app/layout/header.component.test.ts": { - "local/no-generated-api-import": { - "count": 1 - } - }, "src/app/layout/header.component.ts": { "local/no-generated-api-import": { "count": 1 diff --git a/playwright.config.ts b/playwright.config.ts index 55dbd0bb7..adfb34bb2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -92,6 +92,8 @@ const BACKEND_SPECS = [ 'client-transfer.spec.ts', 'client-legal-form-search.spec.ts', 'deposit-account-servicing.spec.ts', + 'double-submit-backend.spec.ts', + 'tenant-date-defaults-backend.spec.ts', 'deposit-lifecycle-backend.spec.ts', 'deposit-product-configuration.spec.ts', 'full-demo.spec.ts', diff --git a/src/app/app.config.ts b/src/app/app.config.ts index c46c40c2b..b1ad929cc 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -39,8 +39,10 @@ import { routes } from './app.routes'; import { authInterceptor } from './core/interceptors/auth.interceptor'; import { correlationIdInterceptor } from './core/interceptors/correlation-id.interceptor'; import { errorInterceptor } from './core/interceptors/error.interceptor'; +import { idempotencyInterceptor } from './core/interceptors/idempotency.interceptor'; import { loadingInterceptor } from './core/interceptors/loading.interceptor'; import { retryInterceptor } from './core/interceptors/retry.interceptor'; +import { serverClockInterceptor } from './core/interceptors/server-clock.interceptor'; import { GlobalErrorHandler } from './core/errors/global-error-handler'; import { ConfigService } from './core/services/config.service'; import { BrandingService } from './core/services/branding.service'; @@ -103,6 +105,12 @@ export const appConfig: ApplicationConfig = { authInterceptor, errorInterceptor, loadingInterceptor, + // After `loadingInterceptor` so a collapsed double submit counts as the one request it + // becomes, and before `retryInterceptor` so a retried attempt carries the same + // idempotency key as the attempt it repeats. + idempotencyInterceptor, + // Last, so it sees the response of every attempt rather than only the first. + serverClockInterceptor, retryInterceptor, ]), ), diff --git a/src/app/core/adapters/api/api-surface.json b/src/app/core/adapters/api/api-surface.json index 2c53f014a..48f6e1eaa 100644 --- a/src/app/core/adapters/api/api-surface.json +++ b/src/app/core/adapters/api/api-surface.json @@ -285,7 +285,11 @@ "postGlaccountsUploadtemplate", "putGlaccountsGlAccountId" ], - "GlobalConfigurationService": ["getConfigurations", "putConfigurationsConfigId"], + "GlobalConfigurationService": [ + "getConfigurations", + "getConfigurationsNameName", + "putConfigurationsConfigId" + ], "GroupsLevelService": ["getGrouplevels"], "GroupsService": [ "getGroups", diff --git a/src/app/core/adapters/api/business-date.api.ts b/src/app/core/adapters/api/business-date.api.ts index 4bec9c100..25429e34c 100644 --- a/src/app/core/adapters/api/business-date.api.ts +++ b/src/app/core/adapters/api/business-date.api.ts @@ -31,6 +31,17 @@ export interface BusinessDateEntry { /** Application contract for reading the platform's business dates. */ export interface BusinessDateApi { getBusinessDates(): Observable; + + /** + * Whether the business-date feature is switched on for this tenant. + * + * Asked because `GET /businessdate` answers either way. The rows persist after the feature is + * disabled, and they stop being maintained — measured on a running Fineract, a tenant with the + * feature off still reported `BUSINESS_DATE 2026-10-17` on the 18th, and + * `POST /businessdate` refused to update it with `business.date.is.not.enabled`. A screen that + * read the row without asking this seeded every date field a day in the past. + */ + isBusinessDateEnabled(): Observable; } export const BUSINESS_DATE_API = new InjectionToken('BUSINESS_DATE_API', { diff --git a/src/app/core/adapters/api/fineract-business-date.api.test.ts b/src/app/core/adapters/api/fineract-business-date.api.test.ts index 8874b4841..ae4629187 100644 --- a/src/app/core/adapters/api/fineract-business-date.api.test.ts +++ b/src/app/core/adapters/api/fineract-business-date.api.test.ts @@ -17,29 +17,89 @@ * under the License. */ +import { HttpContext } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; -import { BusinessDateManagementService } from '../../../api'; +import { BusinessDateManagementService, GlobalConfigurationService } from '../../../api'; import { FineractBusinessDateApi } from './fineract-business-date.api'; +import { RETRY, SKIP_ERROR_TOAST } from '../../http/http-context'; describe('FineractBusinessDateApi', () => { let serviceMock: { getBusinessdate: ReturnType; }; + let configurationsMock: { + getConfigurationsNameName: ReturnType; + }; let api: FineractBusinessDateApi; beforeEach(() => { serviceMock = { getBusinessdate: vi.fn(), }; + configurationsMock = { + getConfigurationsNameName: vi.fn().mockReturnValue(of({ enabled: true })), + }; TestBed.configureTestingModule({ - providers: [{ provide: BusinessDateManagementService, useValue: serviceMock }], + providers: [ + { provide: BusinessDateManagementService, useValue: serviceMock }, + { provide: GlobalConfigurationService, useValue: configurationsMock }, + ], }); api = TestBed.inject(FineractBusinessDateApi); }); + /** + * Whether the feature is on has to be asked separately, because `GET /businessdate` answers + * either way: measured, a tenant with the feature disabled still reported + * `BUSINESS_DATE 2026-10-17` on the 18th, and `POST /businessdate` refused to move it with + * `business.date.is.not.enabled`. + */ + describe('isBusinessDateEnabled', () => { + it('reads the tenant setting by name, quietly and without retrying', async () => { + const enabled = await new Promise((resolve) => + api.isBusinessDateEnabled().subscribe(resolve), + ); + + const call = configurationsMock.getConfigurationsNameName.mock.lastCall as [ + string, + unknown, + unknown, + { context: HttpContext }, + ]; + const options = call[3]; + expect(call[0]).toBe('enable-business-date'); + expect(enabled).toBe(true); + + // Asked on behalf of a date default, with a safe answer when it fails: three retries and + // an error toast for a tenant setting is noise on every screen that seeds a date. + expect(options.context.get(SKIP_ERROR_TOAST)).toBe(true); + expect(options.context.get(RETRY)).toBe(0); + }); + + it('answers false for a disabled setting', async () => { + configurationsMock.getConfigurationsNameName.mockReturnValue(of({ enabled: false })); + + expect(await new Promise((resolve) => api.isBusinessDateEnabled().subscribe(resolve))).toBe( + false, + ); + }); + + it('answers false when the setting cannot be read', async () => { + // Not being able to read the setting is not evidence that a stored date governs, and the + // caller's fallback — the platform's own clock — is the safer of the two answers. + configurationsMock.getConfigurationsNameName.mockReturnValue( + throwError(() => new Error('403')), + ); + + expect(await new Promise((resolve) => api.isBusinessDateEnabled().subscribe(resolve))).toBe( + false, + ); + }); + }); + it('maps business dates from generated service', async () => { serviceMock.getBusinessdate.mockReturnValue( of([ diff --git a/src/app/core/adapters/api/fineract-business-date.api.ts b/src/app/core/adapters/api/fineract-business-date.api.ts index 40ee7df53..ebacb2e1f 100644 --- a/src/app/core/adapters/api/fineract-business-date.api.ts +++ b/src/app/core/adapters/api/fineract-business-date.api.ts @@ -18,10 +18,11 @@ */ import { Injectable, inject } from '@angular/core'; -import { map } from 'rxjs/operators'; -import type { Observable } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { of, type Observable } from 'rxjs'; +import { skipErrorToast, withRetry } from '../../http/http-context'; -import { BusinessDateManagementService } from '../../../api'; +import { BusinessDateManagementService, GlobalConfigurationService } from '../../../api'; import type { BusinessDateApi, BusinessDateEntry } from './business-date.api'; /** @@ -30,18 +31,45 @@ import type { BusinessDateApi, BusinessDateEntry } from './business-date.api'; * One of the few places allowed to import `src/app/api` — see ADR 0006 and the `files` override * in `eslint.config.js`. */ +/** The tenant setting that decides whether the business date governs anything. */ +const BUSINESS_DATE_SETTING = 'enable-business-date'; + @Injectable({ providedIn: 'root' }) export class FineractBusinessDateApi implements BusinessDateApi { private readonly businessDateService = inject(BusinessDateManagementService); + private readonly configurations = inject(GlobalConfigurationService); + + /** + * Whether the business-date feature is on. + * + * `GET /configurations/name/enable-business-date`. A failure answers `false` rather than + * propagating: not being able to read the setting is not evidence that a stored date governs, + * and the caller's fallback — the platform's own clock — is the safer of the two answers. + */ + isBusinessDateEnabled(): Observable { + return this.configurations + .getConfigurationsNameName(BUSINESS_DATE_SETTING, 'body', false, { + // Neither retried nor toasted. It is asked on behalf of a date default, it has a safe + // answer when it fails, and the user did not ask for it — three retries and a toast for + // a tenant setting is noise on every screen that seeds a date. + context: skipErrorToast(withRetry(0)), + }) + .pipe( + map((setting) => (setting as { enabled?: boolean }).enabled === true), + catchError(() => of(false)), + ); + } getBusinessDates(): Observable { - return this.businessDateService.getBusinessdate().pipe( - map((dates) => - (dates || []).map((d) => ({ - type: d.type ?? '', - date: d.date ?? null, - })), - ), - ); + return this.businessDateService + .getBusinessdate('body', false, { context: skipErrorToast(withRetry(0)) }) + .pipe( + map((dates) => + (dates || []).map((d) => ({ + type: d.type ?? '', + date: d.date ?? null, + })), + ), + ); } } diff --git a/src/app/core/adapters/index.ts b/src/app/core/adapters/index.ts index 3e8d843dd..5a74f4bf8 100644 --- a/src/app/core/adapters/index.ts +++ b/src/app/core/adapters/index.ts @@ -40,6 +40,7 @@ export * from './api/office.api'; export * from './api/entity-notes.api'; export * from './api/loan.api'; export * from './api/loan-transaction.api'; +export * from './api/business-date.api'; export * from './api/deposit-account.api'; export * from './api/deposit-product.api'; export * from './api/staff.api'; @@ -59,6 +60,7 @@ export { BrowserDownloadAdapter } from './download/browser-download.adapter'; export { FineractAccountingClosureApi } from './api/fineract-accounting-closure.api'; export { FineractOfficeApi } from './api/fineract-office.api'; export { FineractEntityNotesApi } from './api/fineract-entity-notes.api'; +export { FineractBusinessDateApi } from './api/fineract-business-date.api'; export { FineractLoanApi } from './api/fineract-loan.api'; export { FineractLoanTransactionApi } from './api/fineract-loan-transaction.api'; export { FineractDepositProductApi } from './api/fineract-deposit-product.api'; diff --git a/src/app/core/interceptors/idempotency.interceptor.test.ts b/src/app/core/interceptors/idempotency.interceptor.test.ts new file mode 100644 index 000000000..e51bdfe5e --- /dev/null +++ b/src/app/core/interceptors/idempotency.interceptor.test.ts @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { IDEMPOTENCY_KEY_HEADER, idempotencyInterceptor } from './idempotency.interceptor'; + +/** + * #756. One double-click on the loan repayment form posted two repayments, both `200`. + * + * The forms' `isSaving` flag is bound to `[disabled]`, which the second click of a double-click + * beats to the DOM. These pin the two things that make the second click harmless: the request is + * not sent twice, and if it ever did leave the browser twice it would carry the same key, which + * Fineract honours — measured, two keyed repayments produced one transaction. + */ +describe('idempotencyInterceptor', () => { + let http: HttpClient; + let backend: HttpTestingController; + + const REPAYMENT = '/loans/2/transactions?command=repayment'; + const BODY = { transactionAmount: 100, paymentTypeId: 1 }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([idempotencyInterceptor])), + provideHttpClientTesting(), + ], + }); + http = TestBed.inject(HttpClient); + backend = TestBed.inject(HttpTestingController); + }); + + afterEach(() => backend.verify()); + + it('issues one request for a double submit, and answers both callers', () => { + const answers: unknown[] = []; + http.post(REPAYMENT, BODY).subscribe((result) => answers.push(result)); + http.post(REPAYMENT, BODY).subscribe((result) => answers.push(result)); + + // The whole point: one POST reached the platform, so one repayment was recorded. + const request = backend.expectOne(REPAYMENT); + request.flush({ resourceId: 5 }); + + // And the second click is not left hanging — it is told the same thing as the first. + expect(answers).toEqual([{ resourceId: 5 }, { resourceId: 5 }]); + }); + + it('still answers the second caller when the first walks away', () => { + // A navigation, or a destroyed dialog, unsubscribes the first caller. The request has + // already been applied server-side, so it is not cancelled — and the second click, which is + // the one still on screen, is told what happened. + const answers: unknown[] = []; + const first = http.post(REPAYMENT, BODY).subscribe((result) => answers.push(result)); + http.post(REPAYMENT, BODY).subscribe((result) => answers.push(result)); + + first.unsubscribe(); + backend.expectOne(REPAYMENT).flush({ resourceId: 5 }); + + expect(answers).toEqual([{ resourceId: 5 }]); + }); + + it('sends an idempotency key on a mutating request', () => { + http.post(REPAYMENT, BODY).subscribe(); + + const request = backend.expectOne(REPAYMENT); + expect(request.request.headers.get(IDEMPOTENCY_KEY_HEADER)).toMatch(/\S/); + request.flush({}); + }); + + it('gives a second, deliberate submission its own key and its own request', () => { + // Deliberately posting the same amount twice is legitimate — a customer paying two + // instalments in one visit. Only the *in-flight* window is collapsed, so once the first has + // settled the second is a real request again. + http.post(REPAYMENT, BODY).subscribe(); + const first = backend.expectOne(REPAYMENT); + const firstKey = first.request.headers.get(IDEMPOTENCY_KEY_HEADER); + first.flush({ resourceId: 5 }); + + http.post(REPAYMENT, BODY).subscribe(); + const second = backend.expectOne(REPAYMENT); + const secondKey = second.request.headers.get(IDEMPOTENCY_KEY_HEADER); + second.flush({ resourceId: 6 }); + + expect(secondKey).not.toBe(firstKey); + }); + + it('does not collapse two requests that would do different things', () => { + http.post(REPAYMENT, { transactionAmount: 100 }).subscribe(); + http.post(REPAYMENT, { transactionAmount: 250 }).subscribe(); + + const requests = backend.match(REPAYMENT); + expect(requests).toHaveLength(2); + for (const request of requests) request.flush({}); + }); + + it('separates requests by command, which lives in the query', () => { + // `?command=` is how Fineract distinguishes approve from disburse on the same path, so the + // query has to be part of what makes a request distinct. + http.post('/loans/2?command=approve', {}).subscribe(); + http.post('/loans/2?command=disburse', {}).subscribe(); + + backend.expectOne('/loans/2?command=approve').flush({}); + backend.expectOne('/loans/2?command=disburse').flush({}); + }); + + it('shares a failure with both callers, and then lets the form try again', () => { + const errors: number[] = []; + http.post(REPAYMENT, BODY).subscribe({ error: (e) => errors.push(e.status) }); + http.post(REPAYMENT, BODY).subscribe({ error: (e) => errors.push(e.status) }); + + backend.expectOne(REPAYMENT).flush({}, { status: 500, statusText: 'Server Error' }); + expect(errors).toEqual([500, 500]); + + // The entry is gone once it settled, so a retry after an error is a real request — a form + // whose save failed has to be saveable. + http.post(REPAYMENT, BODY).subscribe({ error: () => undefined }); + backend.expectOne(REPAYMENT).flush({}, { status: 500, statusText: 'Server Error' }); + }); + + it('leaves GET alone entirely', () => { + http.get('/loans/2').subscribe(); + http.get('/loans/2').subscribe(); + + const requests = backend.match('/loans/2'); + expect(requests).toHaveLength(2); + expect(requests[0].request.headers.has(IDEMPOTENCY_KEY_HEADER)).toBe(false); + for (const request of requests) request.flush({}); + }); + + it('keys a PUT and a DELETE too, and collapses their duplicates', () => { + http.put('/clients/2', { firstname: 'Edited' }).subscribe(); + http.put('/clients/2', { firstname: 'Edited' }).subscribe(); + const put = backend.expectOne({ method: 'PUT', url: '/clients/2' }); + expect(put.request.headers.get(IDEMPOTENCY_KEY_HEADER)).toMatch(/\S/); + put.flush({}); + + http.delete('/clients/2').subscribe(); + http.delete('/clients/2').subscribe(); + const remove = backend.expectOne({ method: 'DELETE', url: '/clients/2' }); + expect(remove.request.headers.get(IDEMPOTENCY_KEY_HEADER)).toMatch(/\S/); + remove.flush({}); + }); + + it('keys but does not collapse an upload, whose body cannot be compared', () => { + // Two file uploads with the same name are not the same upload, and a FormData body cannot be + // compared by value — so these stay two requests. + const body = new FormData(); + body.append('file', new Blob(['x']), 'statement.csv'); + http.post('/documents', body).subscribe(); + http.post('/documents', body).subscribe(); + + const requests = backend.match('/documents'); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.request.headers.get(IDEMPOTENCY_KEY_HEADER)).toMatch(/\S/); + request.flush({}); + } + }); +}); diff --git a/src/app/core/interceptors/idempotency.interceptor.ts b/src/app/core/interceptors/idempotency.interceptor.ts new file mode 100644 index 000000000..4cca0eca9 --- /dev/null +++ b/src/app/core/interceptors/idempotency.interceptor.ts @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpEvent, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; +import { Observable, ReplaySubject, finalize, share } from 'rxjs'; + +/** + * Makes a double submit do one thing. + * + * Save buttons across this application set an `isSaving` flag and bind it to `[disabled]`, which + * is not a guard: the second click of a double-click, and a second Enter press, both land before + * that flag reaches the DOM. Measured on a running Fineract (#756), one double-click on the loan + * repayment form posted **two** repayments, each `200` — the borrower's balance and the GL were + * both wrong, and someone had to notice and reverse one. Create Client made two clients the same + * way. Commands guarded by a state transition — approve, disburse — were protected only by + * accident, because the platform refused the second one. + * + * This fixes it once, for every form, rather than at ninety-nine call sites: + * + * **1. Identical requests in flight are collapsed.** The second subscriber gets the first + * request's response, and no second request is issued. The window is exactly the first request's + * flight — not a timed cache — so two *deliberate* identical submissions remain two requests as + * long as the first has finished, which no human double-click can arrange. + * + * **2. Every mutating request carries an idempotency key.** Fineract honours it: measured, two + * `POST /loans/{id}/transactions?command=repayment` with the same `Idempotency-Key` produced one + * transaction and returned the same `resourceId` twice, and two `POST /clients` with one key + * produced one client where keyless duplicates produced two. So even a retry that leaves the + * browser — a dropped connection, a proxy replay — cannot double-post. + * + * The key is the application's half of what {@link IDEMPOTENT} describes: "a POST with a + * client-supplied idempotency key". Retrying such a request is now meaningful rather than + * reckless, though nothing here opts commands into `retryInterceptor` — that decision stays with + * the call site. + */ + +/** + * The header Fineract reads the key from. + * + * Its own default (`fineract.idempotency-key-header-name`). Sent on every mutating request, which + * a server that does not implement it simply ignores. + */ +export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; + +/** Methods that change something, and so can be double-applied. */ +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * In-flight mutating requests, by what they would do. + * + * Module-level rather than per-injector: the interceptor is a function, and the window being + * guarded is a few hundred milliseconds of a single user's clicking. Entries remove themselves + * when the request settles, so this holds nothing between submissions. + */ +const inFlight = new Map>>(); + +/** + * What this request would do, as a string. + * + * Method, URL *with* its query (the command is in there — `?command=repayment`), and the body. + * A body that cannot be compared by value — a file upload, a blob — makes the request + * un-fingerprintable, and those are left alone rather than guessed at. + */ +function fingerprint(req: HttpRequest): string | null { + const body = req.body; + if (body === null || body === undefined) { + return `${req.method} ${req.urlWithParams} -`; + } + if (typeof body === 'string') { + return `${req.method} ${req.urlWithParams} ${body}`; + } + if (typeof body === 'object') { + if (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer) { + return null; + } + try { + return `${req.method} ${req.urlWithParams} ${JSON.stringify(body)}`; + } catch { + // A circular or otherwise unserialisable body: not comparable, so not collapsed. + return null; + } + } + return `${req.method} ${req.urlWithParams} ${String(body)}`; +} + +/** + * A key unique to this submission. + * + * `crypto.randomUUID` where it exists, which is every browser this application supports and the + * Node used by the unit tests. The fallback is only for an insecure context, where `crypto` is + * unavailable; collisions there would merely mean the platform treating two unrelated commands as + * one retry, so it still has to be unguessable rather than sequential. + */ +function newKey(): string { + const generator = globalThis.crypto; + if (generator && typeof generator.randomUUID === 'function') { + return generator.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; +} + +export const idempotencyInterceptor: HttpInterceptorFn = (req, next) => { + if (!MUTATING_METHODS.has(req.method)) { + return next(req); + } + + const key = fingerprint(req); + if (key === null) { + return next(req.clone({ setHeaders: { [IDEMPOTENCY_KEY_HEADER]: newKey() } })); + } + + const existing = inFlight.get(key); + if (existing) { + return existing; + } + + const shared = next(req.clone({ setHeaders: { [IDEMPOTENCY_KEY_HEADER]: newKey() } })).pipe( + finalize(() => inFlight.delete(key)), + // `resetOnRefCountZero: false` deliberately: the request is not cancelled when the first + // subscriber walks away. A posted repayment that is cancelled mid-flight has still been + // applied server-side, and the second subscriber — the double-click — still needs the + // answer. The replay buffer is what lets it arrive late and still be told. + share({ + connector: () => new ReplaySubject>(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false, + }), + ); + + inFlight.set(key, shared); + return shared; +}; diff --git a/src/app/core/interceptors/server-clock.interceptor.test.ts b/src/app/core/interceptors/server-clock.interceptor.test.ts new file mode 100644 index 000000000..41b3fc97b --- /dev/null +++ b/src/app/core/interceptors/server-clock.interceptor.test.ts @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { serverClockInterceptor } from './server-clock.interceptor'; +import { PlatformDateService } from '../services/platform-date.service'; +import { BUSINESS_DATE_API } from '../adapters/api/business-date.api'; +import { ConfigService } from '../services/config.service'; +import { createSpyObj } from '../../testing/mocks'; +import { of } from 'rxjs'; + +/** + * The platform's clock, learned from traffic the application already makes. + * + * #758: a browser ahead of the tenant seeds a date the platform refuses as "in the future". The + * `Date` header is on every response and needs no request of its own. + */ +describe('serverClockInterceptor', () => { + let http: HttpClient; + let backend: HttpTestingController; + let platformDate: PlatformDateService; + + beforeEach(() => { + const businessDateApi = createSpyObj(['getBusinessDates', 'isBusinessDateEnabled']); + businessDateApi.getBusinessDates.mockReturnValue(of([])); + businessDateApi.isBusinessDateEnabled.mockReturnValue(of(false)); + + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverClockInterceptor])), + provideHttpClientTesting(), + { provide: BUSINESS_DATE_API, useValue: businessDateApi }, + // No tenant timezone configured, so the recorded instant is read as it comes. + { provide: ConfigService, useValue: { config: () => ({}) } }, + ], + }); + http = TestBed.inject(HttpClient); + backend = TestBed.inject(HttpTestingController); + platformDate = TestBed.inject(PlatformDateService); + }); + + afterEach(() => backend.verify()); + + it('takes the date from any response and makes it the platform today', () => { + http.get('/clients').subscribe(); + + backend.expectOne('/clients').flush([], { headers: { Date: 'Thu, 15 Oct 2026 09:00:00 GMT' } }); + + expect(platformDate.today()).toBe('2026-10-15'); + }); + + it('keeps the latest response, so a long session does not drift', () => { + http.get('/clients').subscribe(); + backend.expectOne('/clients').flush([], { headers: { Date: 'Thu, 15 Oct 2026 09:00:00 GMT' } }); + + http.get('/loans').subscribe(); + backend.expectOne('/loans').flush([], { headers: { Date: 'Fri, 16 Oct 2026 09:00:00 GMT' } }); + + expect(platformDate.today()).toBe('2026-10-16'); + }); + + it('leaves the date alone when a response carries no usable header', () => { + http.get('/clients').subscribe(); + backend.expectOne('/clients').flush([], { headers: { Date: 'not a date' } }); + + // Falls through to the browser's date rather than to `Invalid Date`. + expect(platformDate.today()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('does not swallow the response', () => { + let body: unknown; + http.get('/clients').subscribe((result) => (body = result)); + backend.expectOne('/clients').flush([{ id: 1 }]); + + expect(body).toEqual([{ id: 1 }]); + }); +}); diff --git a/src/app/core/interceptors/server-clock.interceptor.ts b/src/app/core/interceptors/server-clock.interceptor.ts new file mode 100644 index 000000000..b36aebd5a --- /dev/null +++ b/src/app/core/interceptors/server-clock.interceptor.ts @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpInterceptorFn, HttpResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { tap } from 'rxjs/operators'; + +import { PlatformDateService } from '../services/platform-date.service'; + +/** + * Learns the platform's clock from the traffic the application already makes. + * + * Every HTTP response carries a `Date` header, and it is one of the few headers a browser + * exposes without the server opting in (CORS-safelisted). Reading it costs nothing and removes + * the browser's clock from every date this application defaults — which is the defect behind + * #758: a browser ahead of the tenant offers a date the platform refuses as "in the future", + * and the user is left editing dates by trial and error. + * + * Deliberately not a request of its own. A clock that needs fetching would be stale by the time + * a form asked for it, and would add a round trip to every cold start. + */ +export const serverClockInterceptor: HttpInterceptorFn = (req, next) => { + const platformDate = inject(PlatformDateService); + return next(req).pipe( + tap((event) => { + if (!(event instanceof HttpResponse)) return; + const header = event.headers.get('Date'); + if (header) { + platformDate.recordServerTime(new Date(header)); + } + }), + ); +}; diff --git a/src/app/core/services/platform-date.service.test.ts b/src/app/core/services/platform-date.service.test.ts index efe4cf159..9d8744a99 100644 --- a/src/app/core/services/platform-date.service.test.ts +++ b/src/app/core/services/platform-date.service.test.ts @@ -34,8 +34,16 @@ describe('PlatformDateService', () => { let businessDateApiSpy: SpyObj; let tenantTimezone = 'Asia/Kolkata'; - function setup(dates: BusinessDateEntry[] | 'error' = []): void { - businessDateApiSpy = createSpyObj(['getBusinessDates']); + /** + * @param dates - what `GET /businessdate` answers, or `'error'`. + * @param enabled - whether the tenant has the business-date feature switched on. Defaults to + * `true` so the cases below read as they did; the disabled case is its own test, because a + * disabled feature leaves stale rows behind and that is the trap this service exists to + * avoid. + */ + function setup(dates: BusinessDateEntry[] | 'error' = [], enabled = true): void { + businessDateApiSpy = createSpyObj(['getBusinessDates', 'isBusinessDateEnabled']); + businessDateApiSpy.isBusinessDateEnabled.mockReturnValue(of(enabled)); if (dates === 'error') { businessDateApiSpy.getBusinessDates.mockReturnValue(throwError(() => new Error('403'))); } else { @@ -65,6 +73,70 @@ describe('PlatformDateService', () => { }); describe('tenant timezone date resolution', () => { + it('does not read the business date at all when the feature is off', () => { + // Measured: the rows persist after the feature is disabled and stop being maintained — a + // tenant with it off still reported `BUSINESS_DATE 2026-10-17` on the 18th, and + // `POST /businessdate` refused to move it with `business.date.is.not.enabled`. Seeding + // from that row put every date field a day in the past. + setup([{ type: 'BUSINESS_DATE', date: '2026-10-17' }], false); + + let emitted: string | undefined; + service.resolveToday().subscribe((date) => (emitted = date)); + + expect(businessDateApiSpy.getBusinessDates).not.toHaveBeenCalled(); + expect(service.getBusinessDate()).toBeNull(); + expect(emitted).toBe(service.getTenantToday()); + }); + + it('prefers the platform clock over the browser clock', () => { + // The browser is the one party whose opinion about "today" the platform does not share. + tenantTimezone = ''; + setup(); + service.recordServerTime(new Date('2026-08-15T10:00:00Z')); + + expect(service.getTenantToday()).toBe('2026-08-15'); + expect(service.today()).toBe('2026-08-15'); + }); + + it('ignores an unreadable Date header rather than taking NaN for a date', () => { + tenantTimezone = ''; + setup(); + service.recordServerTime(new Date('not a date')); + + expect(service.today()).toBe(toIsoDate(new Date())); + }); + + it('reads the platform clock in the tenant timezone when one is configured', () => { + // 2026-08-15 20:00Z is already the 16th in Asia/Kolkata (UTC+5:30). + setup(); + service.recordServerTime(new Date('2026-08-15T20:00:00Z')); + + expect(service.today()).toBe('2026-08-16'); + }); + + it('does not invent a timezone when none is configured', () => { + // This service used to assume Asia/Kolkata, which is a date up to a day away from the + // platform's for every tenant that is not in that zone — the same guess it exists to + // remove. + tenantTimezone = ''; + setup(); + const instant = new Date('2026-08-15T20:00:00Z'); + + expect(service.getTenantToday(instant)).toBe(toIsoDate(instant)); + }); + + it('resolves once however many screens ask', () => { + // Five places used to read `GET /businessdate` for themselves. + setup([{ type: 'BUSINESS_DATE', date: '2026-03-04' }]); + + service.resolveToday().subscribe(); + service.resolveToday().subscribe(); + service.loadBusinessDate().subscribe(); + + expect(businessDateApiSpy.isBusinessDateEnabled).toHaveBeenCalledTimes(1); + expect(businessDateApiSpy.getBusinessDates).toHaveBeenCalledTimes(1); + }); + it('defaults to the tenant timezone today when business date is not set', () => { const now = new Date(); const expected = service.getTenantToday(now); diff --git a/src/app/core/services/platform-date.service.ts b/src/app/core/services/platform-date.service.ts index 24078f3b2..f0a663f80 100644 --- a/src/app/core/services/platform-date.service.ts +++ b/src/app/core/services/platform-date.service.ts @@ -17,49 +17,106 @@ * under the License. */ -import { Injectable, computed, inject, signal } from '@angular/core'; -import { Observable, catchError, map, of } from 'rxjs'; -import { BUSINESS_DATE_API } from '../adapters/api/business-date.api'; +import { Injectable, Injector, computed, inject, signal } from '@angular/core'; +import { Observable, catchError, map, of, shareReplay, switchMap, tap } from 'rxjs'; +import { BUSINESS_DATE_API, type BusinessDateApi } from '../adapters/api/business-date.api'; import { formatArrayDate, toIsoDate } from '../utils/date-formatter'; import { ConfigService } from './config.service'; /** * Resolves "what is today, according to the platform". * - * Fineract validates and stamps records in the *tenant's* timezone (conventionally `Asia/Kolkata`), - * or according to its configured business date (`GET /v1/businessdate`). If a client seeds date - * fields from the browser's clock (`new Date()`), users in a timezone behind the tenant (such as UTC) - * will offer yesterday's date during the evening hours (e.g., 18:30 to 24:00 UTC). When activating - * or closing records, the platform refuses the submission with: - * `error.msg.group.submittedOnDate.after.activation.date`. + * Fineract judges whether a date is in the future by its own reckoning, not the browser's: the + * tenant's timezone, or the business date when that feature is on. A form that seeds a date from + * `new Date()` therefore offers a day the platform may refuse. Measured (#758): a browser in + * `Asia/Kolkata` at 11:25 local, against a tenant then on the 15th, had Create Client refused + * with `error.msg.clients.submittedOnDate.in.the.future` and Create Loan with a `403` reading + * "The date on which a loan is submitted cannot be in the future" — which looks like a + * permission problem and is not one. * - * This service acts as the single source of truth for platform date seeding across dialogs: - * 1. If configured on the tenant, the active business date (`type === 'BUSINESS_DATE'`). - * 2. If the business date module is unconfigured (returns `[]` or 403), the current calendar date - * in the tenant's timezone (`Asia/Kolkata`, or configured in `AppConfig.tenantTimezone`). - * 3. The local browser date as the ultimate fallback. + * The order of preference, each step measured against a running platform: + * + * 1. **The business date, when the feature is enabled.** Enabled is the operative word. The rows + * behind `GET /businessdate` persist after the feature is switched off and stop being + * maintained: a tenant with it disabled still reported `BUSINESS_DATE 2026-10-17` on the 18th. + * Seeding from that row unasked puts every date field a day in the past, which is why + * {@link BusinessDateApi.isBusinessDateEnabled} is consulted first. + * 2. **The platform's own clock**, taken from the `Date` header every response carries and + * recorded by `serverClockInterceptor`. This is what removes the browser's clock from the + * decision, which is the actual defect — the browser is the one party whose opinion the + * platform does not share. + * 3. **The browser's date**, when nothing has been heard from the server yet. + * + * There is deliberately no hardcoded tenant timezone. This service used to assume + * `Asia/Kolkata` whenever `AppConfig.tenantTimezone` was unset, which invents a date up to a day + * away from the platform's for every tenant that is not in that zone — the same class of guess + * the service exists to remove. A configured timezone is still honoured, and applied to the + * *server's* instant rather than the browser's. + * + * Being behind the platform is safe and being ahead is not: a date in the past is accepted + * (back-dating is a normal operation), while a date in the future is refused. Where the two + * sources disagree, the earlier one is the one that submits. */ +/** + * How long a resolved platform date is reused before being asked for again. + * + * Long enough that the screens opening in one interaction share a single pair of requests, short + * enough that a session left open across close-of-business picks up the new business date. + */ +const RESOLUTION_TTL_MS = 5 * 60 * 1000; + @Injectable({ providedIn: 'root' }) export class PlatformDateService { - private readonly businessDateApi = inject(BUSINESS_DATE_API); + private readonly injector = inject(Injector); private readonly configService = inject(ConfigService); + /** The adapter, resolved on first use. `null` once it is known to be unavailable. */ + private api: BusinessDateApi | null | undefined; + private readonly _businessDate = signal(null); readonly businessDate = this._businessDate.asReadonly(); + /** The platform's clock, as last reported by a response's `Date` header. */ + private readonly serverTime = signal(null); + + /** Resolved once and shared, so five screens asking do not make five requests. */ + private resolution$: Observable | null = null; + private enabled$: Observable | null = null; + private resolvedAt = 0; + /** - * The resolved platform date string (`YYYY-MM-DD`). - * Reflects the configured business date if available, or today in the tenant's timezone. + * The resolved platform date (`YYYY-MM-DD`), synchronously. + * + * The business date when one governs, otherwise today by the platform's clock. Available + * immediately — before anything has been read it answers the browser's date, which is the + * only thing then known. */ readonly today = computed(() => this._businessDate() ?? this.getTenantToday()); /** - * Returns the current date according to the tenant's timezone. + * Records the platform's clock from a response. + * + * Called by `serverClockInterceptor` for every response that carries a `Date` header. Taking + * it from traffic the application already makes keeps this free: no extra request, and it + * stays current through a long session. + */ + recordServerTime(instant: Date): void { + if (!Number.isNaN(instant.getTime())) { + this.serverTime.set(instant); + } + } + + /** + * Today in the tenant's reckoning, from the most authoritative clock available. * - * @param referenceDate - Optional reference date to format; defaults to `new Date()`. + * @param referenceDate - the instant to read. Defaults to the platform's clock when one has + * been seen, and to the browser's otherwise. */ - getTenantToday(referenceDate: Date = new Date()): string { - const timeZone = this.configService.config().tenantTimezone ?? 'Asia/Kolkata'; + getTenantToday(referenceDate: Date = this.serverTime() ?? new Date()): string { + const timeZone = this.configService.config().tenantTimezone; + if (!timeZone) { + return toIsoDate(referenceDate); + } try { const formatter = new Intl.DateTimeFormat('en-CA', { timeZone, @@ -69,62 +126,129 @@ export class PlatformDateService { }); return formatter.format(referenceDate); } catch { + // A misconfigured zone should not take the date with it. return toIsoDate(referenceDate); } } - /** - * Returns today's platform date as an ISO `YYYY-MM-DD` string. - */ + /** Today's platform date as an ISO `YYYY-MM-DD` string. */ getTodayIso(): string { return this.today(); } - /** - * Returns the active business date string (`YYYY-MM-DD`), or `null` if not configured. - */ + /** The business date when one governs, or `null`. */ getBusinessDate(): string | null { return this._businessDate(); } /** - * Loads the configured business date from the platform. - * If present, stores it in the internal signal and emits the resolved date. - * If the endpoint returns empty, an error, or no BUSINESS_DATE, falls back to the tenant today. + * Resolves today, asking the platform if it has not been asked yet. + * + * Shared and replayed: every screen that seeds a date can call this on init, and only the + * first call issues requests. Use it where the default must be right even on a cold load; use + * {@link today} where a synchronous best answer is enough. */ - loadBusinessDate(): Observable { - return this.businessDateApi.getBusinessDates().pipe( - map((dates) => { - const bd = dates.find((d) => d.type === 'BUSINESS_DATE'); - if (bd && bd.date) { - const iso = Array.isArray(bd.date) - ? formatArrayDate(bd.date) - : toIsoDate(bd.date as string); - if (iso && iso !== '-') { - this._businessDate.set(iso); - return iso; - } - } - this._businessDate.set(null); - return this.getTenantToday(); - }), + resolveToday(): Observable { + // Re-resolved after the window has passed. Caching forever would be wrong in both + // directions: a back-office session stays open all day, and the business date moves when + // close-of-business runs, so a value fetched at 09:00 would still be offered at 18:00. + if (this.resolution$ && Date.now() - this.resolvedAt > RESOLUTION_TTL_MS) { + this.resolution$ = null; + } + this.resolvedAt = Date.now(); + const api = this.businessDateApi(); + if (!api) { + return of(this.today()); + } + // The flag is tenant configuration and does not move during a session, so it is asked once + // however many times the date itself is re-read. + this.enabled$ ??= api.isBusinessDateEnabled().pipe( + catchError(() => of(false)), + shareReplay({ bufferSize: 1, refCount: false }), + ); + + this.resolution$ ??= this.enabled$.pipe( + switchMap((enabled) => (enabled ? this.readBusinessDate() : of(null))), + tap((businessDate) => this._businessDate.set(businessDate)), + map(() => this.today()), catchError(() => { this._businessDate.set(null); - return of(this.getTenantToday()); + return of(this.today()); }), + shareReplay({ bufferSize: 1, refCount: false }), ); + return this.resolution$; + } + + /** + * Loads the business date, if one governs. + * + * Kept for the screens that called it before this service resolved the feature flag; it now + * answers the same thing {@link resolveToday} does. + */ + loadBusinessDate(): Observable { + return this.resolveToday(); } /** - * Compares today's platform date with a floor date (e.g. `submittedOnDate` or `activatedOnDate`) - * and returns the later of the two. + * Forgets what was resolved, so the next caller asks the platform again. + * + * For a screen that has just changed the business date, or any point where waiting out the + * five-minute window would mean offering a date the tenant has moved on from. + */ + refresh(): void { + this.resolution$ = null; + this.enabled$ = null; + } + + /** + * Compares today's platform date with a floor date (for example `submittedOnDate` or + * `activatedOnDate`) and returns the later of the two. * * Compared lexically: both sides are zero-padded `YYYY-MM-DD`, which orders identically to the - * dates it spells, keeping browser timezone translation out of the calculation. + * dates it spells, keeping timezone conversion out of the calculation. */ getDateWithFloor(floor?: string): string { const today = this.today(); if (!floor) return today; return floor > today ? floor : today; } + + /** + * The business-date adapter, resolved on first use. + * + * Lazily, so that merely injecting this service does not construct the generated client + * underneath it: every screen that defaults a date depends on this service now, and most of + * them never reach the point of asking. A resolution failure is taken as "nothing to ask" + * rather than propagated, so a date default cannot be what breaks a screen. + * + * This is not a substitute for providing the adapter in a test: an injector that *can* + * construct it will, and the request then needs answering like any other. `provideFakeAdapters` + * binds it to a fake for exactly that reason. + */ + private businessDateApi(): BusinessDateApi | null { + if (this.api === undefined) { + try { + this.api = this.injector.get(BUSINESS_DATE_API); + } catch { + this.api = null; + } + } + return this.api; + } + + private readBusinessDate(): Observable { + const api = this.businessDateApi(); + if (!api) return of(null); + return api.getBusinessDates().pipe( + map((dates) => { + const entry = dates.find((date) => date.type === 'BUSINESS_DATE'); + if (!entry?.date) return null; + const iso = Array.isArray(entry.date) + ? formatArrayDate(entry.date) + : toIsoDate(entry.date as string); + return iso && iso !== '-' ? iso : null; + }), + ); + } } diff --git a/src/app/features/centers/center-form.component.test.ts b/src/app/features/centers/center-form.component.test.ts index 290896a58..30133d44a 100644 --- a/src/app/features/centers/center-form.component.test.ts +++ b/src/app/features/centers/center-form.component.test.ts @@ -18,6 +18,7 @@ */ import { createSpyObj, SpyObj } from '../../testing/mocks'; +import { BUSINESS_DATE_API } from '../../core/adapters'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CenterFormComponent } from './center-form.component'; import { CentersService, OfficesService } from '../../api'; @@ -117,6 +118,16 @@ describe('CenterFormComponent', () => { TestBed.configureTestingModule({ imports: [CenterFormComponent], providers: [ + { + // A tenant without the business-date feature: `PlatformDateService` then seeds this + // form's dates from the platform's clock and asks the backend nothing. Without it the + // service reaches the generated client, and the request would need answering here. + provide: BUSINESS_DATE_API, + useValue: { + getBusinessDates: () => of([]), + isBusinessDateEnabled: () => of(false), + }, + }, ...provideTranslateTesting(), provideNoopAnimations(), { provide: CentersService, useValue: centersServiceSpy }, diff --git a/src/app/features/centers/center-form.component.ts b/src/app/features/centers/center-form.component.ts index 953705154..1cc7a5dd9 100644 --- a/src/app/features/centers/center-form.component.ts +++ b/src/app/features/centers/center-form.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { PlatformDateService } from '../../core/services/platform-date.service'; import { FormsModule } from '@angular/forms'; import { I18N, TranslatePipe } from '../../core/adapters'; import { MutationOutcomeService } from '../../core/services/mutation-outcome.service'; @@ -242,6 +243,7 @@ export class CenterFormComponent implements OnInit { private readonly centersService = inject(CentersService); private readonly officesService = inject(OfficesService); private readonly route = inject(ActivatedRoute); + private readonly platformDate = inject(PlatformDateService); private readonly router = inject(Router); private readonly mutationOutcome = inject(MutationOutcomeService); private readonly i18n = inject(I18N); @@ -262,6 +264,7 @@ export class CenterFormComponent implements OnInit { readonly offices = signal([]); ngOnInit(): void { + this.seedPlatformDates(); this.loadOffices(); this.route.paramMap.subscribe((params) => { const id = params.get('id'); @@ -273,6 +276,22 @@ export class CenterFormComponent implements OnInit { }); } + /** + * Seeds the date defaults from the platform, not the browser. + * + * Fineract judges "the future" by its own reckoning — the tenant's timezone, or the business + * date when that feature is on — so a default taken from `new Date()` is refused whenever the + * browser is ahead of it (#758). Only on create: an edit loads the record's own dates and must + * not have them overwritten. + */ + private seedPlatformDates(): void { + if (this.isEditMode()) return; + this.platformDate.resolveToday().subscribe((today) => { + if (this.isEditMode()) return; + this.activationDate = today; + }); + } + private loadOffices(): void { this.officesService.getOffices(true).subscribe((offices) => { this.offices.set(offices); diff --git a/src/app/features/clients/client-action-dialog.component.test.ts b/src/app/features/clients/client-action-dialog.component.test.ts index b88767fa6..9adf97bb1 100644 --- a/src/app/features/clients/client-action-dialog.component.test.ts +++ b/src/app/features/clients/client-action-dialog.component.test.ts @@ -25,7 +25,9 @@ import { ClientActionDialogComponent, ClientActionDialogData, } from './client-action-dialog.component'; -import { CodesService, CodeValuesService, BusinessDateManagementService } from '../../api'; +import { CodesService, CodeValuesService } from '../../api'; +import { BUSINESS_DATE_API, BusinessDateApi } from '../../core/adapters/api/business-date.api'; +import { PlatformDateService } from '../../core/services/platform-date.service'; import { provideIonicTesting } from '../../testing/ionic-testing'; import { provideTranslateTesting } from '../../testing/i18n-testing'; @@ -35,7 +37,7 @@ describe('ClientActionDialogComponent', () => { let modalControllerSpy: SpyObj; let codesServiceSpy: SpyObj; let codeValuesServiceSpy: SpyObj; - let businessDateServiceSpy: SpyObj; + let businessDateApiSpy: SpyObj; const defaultDialogData: ClientActionDialogData = { title: 'Activate Client', @@ -47,7 +49,7 @@ describe('ClientActionDialogComponent', () => { modalControllerSpy = createSpyObj(['dismiss']); codesServiceSpy = createSpyObj(['getCodesNameCodeName']); codeValuesServiceSpy = createSpyObj(['getCodesCodeIdCodevalues']); - businessDateServiceSpy = createSpyObj(['getBusinessdate']); + businessDateApiSpy = createSpyObj(['getBusinessDates', 'isBusinessDateEnabled']); TestBed.configureTestingModule({ imports: [ClientActionDialogComponent], @@ -57,11 +59,12 @@ describe('ClientActionDialogComponent', () => { { provide: ModalController, useValue: modalControllerSpy }, { provide: CodesService, useValue: codesServiceSpy }, { provide: CodeValuesService, useValue: codeValuesServiceSpy }, - { provide: BusinessDateManagementService, useValue: businessDateServiceSpy }, + { provide: BUSINESS_DATE_API, useValue: businessDateApiSpy }, ], }).compileComponents(); - businessDateServiceSpy.getBusinessdate.mockReturnValue(of([]) as unknown as Observable); + businessDateApiSpy.getBusinessDates.mockReturnValue(of([])); + businessDateApiSpy.isBusinessDateEnabled.mockReturnValue(of(false)); fixture = TestBed.createComponent(ClientActionDialogComponent); component = fixture.componentInstance; @@ -102,9 +105,13 @@ describe('ClientActionDialogComponent', () => { }); it('should use the business date for the action date and maximum', () => { - businessDateServiceSpy.getBusinessdate.mockReturnValue( + businessDateApiSpy.isBusinessDateEnabled.mockReturnValue(of(true)); + businessDateApiSpy.getBusinessDates.mockReturnValue( of([{ type: 'BUSINESS_DATE', date: [2026, 1, 5] }]) as unknown as Observable, ); + // The resolution is cached and shared; the harness already resolved it as a tenant + // without the feature, so this stub applies only after that is discarded. + TestBed.inject(PlatformDateService).refresh(); component.ngOnInit(); diff --git a/src/app/features/clients/client-action-dialog.component.ts b/src/app/features/clients/client-action-dialog.component.ts index 2f90465ac..8933e7a67 100644 --- a/src/app/features/clients/client-action-dialog.component.ts +++ b/src/app/features/clients/client-action-dialog.component.ts @@ -32,13 +32,9 @@ import { IonTextarea, ModalController, } from '@ionic/angular/standalone'; -import { formatArrayDate, toIsoDate } from '../../core/utils/date-formatter'; -import { - CodesService, - CodeValuesService, - GetCodeValuesDataResponse, - BusinessDateManagementService, -} from '../../api'; +import { toIsoDate } from '../../core/utils/date-formatter'; +import { CodesService, CodeValuesService, GetCodeValuesDataResponse } from '../../api'; +import { PlatformDateService } from '../../core/services/platform-date.service'; export interface ClientActionDialogData { title: string; @@ -139,7 +135,7 @@ export interface ClientActionDialogData { export class ClientActionDialogComponent implements OnInit { private readonly codesService = inject(CodesService); private readonly codeValuesService = inject(CodeValuesService); - private readonly businessDateService = inject(BusinessDateManagementService); + private readonly platformDate = inject(PlatformDateService); public readonly modalController = inject(ModalController); readonly data = input.required(); @@ -159,17 +155,22 @@ export class ClientActionDialogComponent implements OnInit { this.loadBusinessDate(); } + /** + * Bounds the picker at the business date, when one governs. + * + * Through `PlatformDateService` rather than reading `GET /businessdate` here: the rows behind + * that endpoint persist after the feature is switched off and stop being maintained, so a + * screen that reads them unasked bounds the picker at a stale day (#758). The service asks + * whether the feature is on first, and answers `null` when it is not. + */ private loadBusinessDate(): void { - this.businessDateService.getBusinessdate().subscribe({ - next: (dates) => { - const bd = dates.find((d) => d.type === 'BUSINESS_DATE'); - if (bd && bd.date) { - // ion-datetime works in ISO strings, including its max bound. - const bDate = formatArrayDate(bd.date); - this.actionDate.set(bDate); - this.maxDate.set(bDate); - } - }, + this.platformDate.resolveToday().subscribe(() => { + // ion-datetime works in ISO strings, including its max bound. + const businessDate = this.platformDate.getBusinessDate(); + if (businessDate) { + this.actionDate.set(businessDate); + this.maxDate.set(businessDate); + } }); } diff --git a/src/app/features/clients/client-form.component.test.ts b/src/app/features/clients/client-form.component.test.ts index 5fcb7d7df..ed152e57a 100644 --- a/src/app/features/clients/client-form.component.test.ts +++ b/src/app/features/clients/client-form.component.test.ts @@ -18,6 +18,7 @@ */ import { createSpyObj, SpyObj } from '../../testing/mocks'; +import { BUSINESS_DATE_API } from '../../core/adapters'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ClientFormComponent } from './client-form.component'; import { ClientService, OfficesService } from '../../api'; @@ -261,6 +262,16 @@ describe('ClientFormComponent', () => { await TestBed.configureTestingModule({ imports: [ClientFormComponent], providers: [ + { + // A tenant without the business-date feature: `PlatformDateService` then seeds this + // form's dates from the platform's clock and asks the backend nothing. Without it the + // service reaches the generated client, and the request would need answering here. + provide: BUSINESS_DATE_API, + useValue: { + getBusinessDates: () => of([]), + isBusinessDateEnabled: () => of(false), + }, + }, ...provideTranslateTesting(), { provide: ClientService, useValue: clientServiceSpy }, { provide: OfficesService, useValue: officesServiceSpy }, diff --git a/src/app/features/clients/client-form.component.ts b/src/app/features/clients/client-form.component.ts index c35fd2edb..08fb78704 100644 --- a/src/app/features/clients/client-form.component.ts +++ b/src/app/features/clients/client-form.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit, afterNextRender, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { PlatformDateService } from '../../core/services/platform-date.service'; import { FormsModule } from '@angular/forms'; import { HelpIconComponent, StepperComponent } from '../../shared'; import { TranslatePipe } from '../../core/adapters'; @@ -603,6 +604,7 @@ export class ClientFormComponent implements OnInit { private readonly officesService = inject(OfficesService); private readonly dialogService = inject(DialogService); private readonly route = inject(ActivatedRoute); + private readonly platformDate = inject(PlatformDateService); private readonly router = inject(Router); private readonly mutationOutcome = inject(MutationOutcomeService); private readonly i18n = inject(I18N); @@ -656,6 +658,7 @@ export class ClientFormComponent implements OnInit { } ngOnInit() { + this.seedPlatformDates(); this.loadOffices(); this.route.paramMap.subscribe((params) => { const id = params.get('id'); @@ -667,6 +670,24 @@ export class ClientFormComponent implements OnInit { }); } + /** + * Seeds the date defaults from the platform, not the browser. + * + * Fineract judges "the future" by its own reckoning — the tenant's timezone, or the business + * date when that feature is on — so a default taken from `new Date()` is refused whenever the + * browser is ahead: `error.msg.clients.submittedOnDate.in.the.future` on this form, and a + * `403` reading like a permission problem on the loan one (#758). Only on create: an edit + * loads the record's own dates and must not have them overwritten. + */ + private seedPlatformDates(): void { + if (this.isEditMode()) return; + this.platformDate.resolveToday().subscribe((today) => { + if (this.isEditMode()) return; + this.submittedOnDate.set(today); + this.activationDate.set(today); + }); + } + loadOffices() { this.officesService.getOffices(true).subscribe((offices) => { this.offices.set(offices); diff --git a/src/app/features/groups/group-action-dialog.component.test.ts b/src/app/features/groups/group-action-dialog.component.test.ts index 0f4cb980f..b4a86ab7c 100644 --- a/src/app/features/groups/group-action-dialog.component.test.ts +++ b/src/app/features/groups/group-action-dialog.component.test.ts @@ -23,7 +23,8 @@ import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { of } from 'rxjs'; import { GroupActionDialogComponent, GroupActionDialogData } from './group-action-dialog.component'; -import { BusinessDateManagementService, GroupsService } from '../../api'; +import { GroupsService } from '../../api'; +import { BUSINESS_DATE_API, BusinessDateApi } from '../../core/adapters/api/business-date.api'; import { provideFakeAdapters } from '../../testing/adapters'; import { toIsoDate } from '../../core/utils/date-formatter'; @@ -41,7 +42,7 @@ const ACTIVATE: GroupActionDialogData['title'] = 'GROUPS.ACTIVATE'; describe('GroupActionDialogComponent', () => { let fixture: ComponentFixture; let component: GroupActionDialogComponent; - let businessDateService: SpyObj; + let businessDateService: SpyObj; /** A date relative to the browser's today, as the ISO string the dialog compares. */ function offsetFromToday(days: number): string { @@ -56,8 +57,9 @@ describe('GroupActionDialogComponent', () => { } async function setup(data: GroupActionDialogData, businessDate?: number[]): Promise { - businessDateService = createSpyObj(['getBusinessdate']); - businessDateService.getBusinessdate.mockReturnValue( + businessDateService = createSpyObj(['getBusinessDates', 'isBusinessDateEnabled']); + businessDateService.isBusinessDateEnabled.mockReturnValue(of(true)); + businessDateService.getBusinessDates.mockReturnValue( of(businessDate ? [{ type: 'BUSINESS_DATE', date: businessDate }] : []) as never, ); @@ -69,7 +71,7 @@ describe('GroupActionDialogComponent', () => { providers: [ provideNoopAnimations(), ...provideFakeAdapters().providers, - { provide: BusinessDateManagementService, useValue: businessDateService }, + { provide: BUSINESS_DATE_API, useValue: businessDateService }, { provide: GroupsService, useValue: groupsService }, ], }).compileComponents(); diff --git a/src/app/features/groups/group-action-dialog.component.ts b/src/app/features/groups/group-action-dialog.component.ts index 153d36002..bdc9e0dd2 100644 --- a/src/app/features/groups/group-action-dialog.component.ts +++ b/src/app/features/groups/group-action-dialog.component.ts @@ -30,7 +30,8 @@ import { IonSelectOption, } from '@ionic/angular/standalone'; -import { BusinessDateManagementService, GroupsService } from '../../api'; +import { GroupsService } from '../../api'; +import { PlatformDateService } from '../../core/services/platform-date.service'; import { OVERLAY, TranslatePipe } from '../../core/adapters'; import { toIsoDate } from '../../core/utils/date-formatter'; @@ -167,7 +168,7 @@ export interface GroupActionResult { export class GroupActionDialogComponent implements OnInit { private readonly overlay = inject(OVERLAY); private readonly groupsService = inject(GroupsService); - private readonly businessDateService = inject(BusinessDateManagementService); + private readonly platformDate = inject(PlatformDateService); readonly data = input.required(); @@ -223,12 +224,10 @@ export class GroupActionDialogComponent implements OnInit { * message names the constraint rather than the field, so the picker enforces it instead. */ private loadBusinessDate(): void { - this.businessDateService.getBusinessdate().subscribe({ - next: (dates) => { - const businessDate = dates.find((entry) => entry.type === 'BUSINESS_DATE'); - const parts = businessDate?.date as unknown as number[] | undefined; - if (parts?.length) { - const iso = toIsoDate(new Date(parts[0], parts[1] - 1, parts[2])); + this.platformDate.resolveToday().subscribe({ + next: () => { + const iso = this.platformDate.getBusinessDate(); + if (iso) { const min = this.minDate(); // A business date earlier than the floor would leave the picker with a range it // cannot satisfy. The floor wins, because it is the constraint the command itself @@ -240,8 +239,9 @@ export class GroupActionDialogComponent implements OnInit { } } }, - // A tenant with the business-date module off answers 403 here. Today's date is the - // right fallback, and the command itself still validates server-side. + // `PlatformDateService` answers `null` for a tenant with the feature off — including when + // a stale row is still stored — so there is nothing to bound the picker with, and the + // command still validates server-side. error: () => undefined, }); } diff --git a/src/app/features/groups/group-form.component.ts b/src/app/features/groups/group-form.component.ts index 9b9a3c4a8..2f764af55 100644 --- a/src/app/features/groups/group-form.component.ts +++ b/src/app/features/groups/group-form.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { PlatformDateService } from '../../core/services/platform-date.service'; import { FormsModule } from '@angular/forms'; import { I18N, TranslatePipe } from '../../core/adapters'; import { MutationOutcomeService } from '../../core/services/mutation-outcome.service'; @@ -244,6 +245,7 @@ export class GroupFormComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly mutationOutcome = inject(MutationOutcomeService); private readonly i18n = inject(I18N); + private readonly platformDate = inject(PlatformDateService); private readonly DATE_FORMAT = 'yyyy-MM-dd'; private readonly LIST_PATH = '/groups'; @@ -261,6 +263,7 @@ export class GroupFormComponent implements OnInit { readonly offices = signal([]); ngOnInit(): void { + this.seedPlatformDates(); this.loadOffices(); this.route.paramMap.subscribe((params) => { const id = params.get('id'); @@ -272,6 +275,22 @@ export class GroupFormComponent implements OnInit { }); } + /** + * Seeds the date defaults from the platform, not the browser. + * + * Fineract judges "the future" by its own reckoning — the tenant's timezone, or the business + * date when that feature is on — so a default taken from `new Date()` is refused whenever the + * browser is ahead of it (#758). Only on create: an edit loads the record's own dates and must + * not have them overwritten. + */ + private seedPlatformDates(): void { + if (this.isEditMode()) return; + this.platformDate.resolveToday().subscribe((today) => { + if (this.isEditMode()) return; + this.activationDate = today; + }); + } + private loadOffices(): void { this.officesService.getOffices(true).subscribe((offices) => { this.offices.set(offices); diff --git a/src/app/features/loans/loan-form.component.ts b/src/app/features/loans/loan-form.component.ts index e440f6579..f7f623e42 100644 --- a/src/app/features/loans/loan-form.component.ts +++ b/src/app/features/loans/loan-form.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { PlatformDateService } from '../../core/services/platform-date.service'; import { FormsModule } from '@angular/forms'; import { I18N, TranslatePipe } from '../../core/adapters'; import { MutationOutcomeService } from '../../core/services/mutation-outcome.service'; @@ -581,6 +582,7 @@ export class LoanFormComponent implements OnInit { private readonly productService = inject(LoanProductsService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly platformDate = inject(PlatformDateService); private readonly notifications = inject(NotificationService); private readonly mutationOutcome = inject(MutationOutcomeService); private readonly i18n = inject(I18N); @@ -613,6 +615,7 @@ export class LoanFormComponent implements OnInit { readonly selectedProductDetails = signal(null); ngOnInit() { + this.seedPlatformDates(); this.loadProducts(); this.route.queryParams.subscribe((queryParams) => { @@ -635,6 +638,23 @@ export class LoanFormComponent implements OnInit { }); } + /** + * Seeds the date defaults from the platform, not the browser. + * + * Fineract judges "the future" by its own reckoning — the tenant's timezone, or the business + * date when that feature is on — so a default taken from `new Date()` is refused whenever the + * browser is ahead of it (#758). Only on create: an edit loads the record's own dates and must + * not have them overwritten. + */ + private seedPlatformDates(): void { + if (this.isEditMode()) return; + this.platformDate.resolveToday().subscribe((today) => { + if (this.isEditMode()) return; + this.submittedOnDate.set(today); + this.expectedDisbursementDate.set(today); + }); + } + onCreateClient() { this.router.navigate(['/clients/create']); } diff --git a/src/app/features/loans/tabs/loan-delinquency-tab.component.test.ts b/src/app/features/loans/tabs/loan-delinquency-tab.component.test.ts index dca4c1545..27c11a0ec 100644 --- a/src/app/features/loans/tabs/loan-delinquency-tab.component.test.ts +++ b/src/app/features/loans/tabs/loan-delinquency-tab.component.test.ts @@ -24,7 +24,8 @@ import { of, throwError } from 'rxjs'; import type { Mock } from 'vitest'; import { LoanDelinquencyTabComponent } from './loan-delinquency-tab.component'; -import { BusinessDateManagementService, LoansService } from '../../../api'; +import { LoansService } from '../../../api'; +import { BUSINESS_DATE_API, BusinessDateApi } from '../../../core/adapters/api/business-date.api'; import { AuthService } from '../../../core/services/auth.service'; import { DialogService } from '../../../core/services/dialog.service'; import { NotificationService } from '../../../core/services/notification.service'; @@ -46,11 +47,26 @@ const ENDED = { active: false, pausePeriodStart: [2026, 8, 1], pausePeriodEnd: [ const IN_EFFECT = { active: true, pausePeriodStart: [2026, 9, 28], pausePeriodEnd: [2026, 10, 15] }; const BUSINESS_DATE = [{ type: 'BUSINESS_DATE', date: [2026, 10, 1] }]; +/** + * A pause that is in effect *now* by the clock, for the cases where no business date governs. + * + * The fixtures above are dated around the mocked business date; this one has to straddle the + * real today, because that is the date the tab falls back to. + */ +function pauseCoveringToday(): Record { + const from = new Date(); + from.setDate(from.getDate() - 2); + const to = new Date(); + to.setDate(to.getDate() + 2); + const parts = (date: Date) => [date.getFullYear(), date.getMonth() + 1, date.getDate()]; + return { active: true, pausePeriodStart: parts(from), pausePeriodEnd: parts(to) }; +} + describe('LoanDelinquencyTabComponent', () => { let fixture: ComponentFixture; let component: LoanDelinquencyTabComponent; let loansService: SpyObj; - let businessDates: SpyObj; + let businessDates: SpyObj; let dialogs: SpyObj; let notifications: SpyObj; let changed: Mock<() => void>; @@ -88,8 +104,9 @@ describe('LoanDelinquencyTabComponent', () => { } loansService.postLoansLoanIdDelinquencyActions.mockReturnValue(of({ resourceId: 9 }) as never); - businessDates = createSpyObj(['getBusinessdate']); - businessDates.getBusinessdate.mockReturnValue( + businessDates = createSpyObj(['getBusinessDates', 'isBusinessDateEnabled']); + businessDates.isBusinessDateEnabled.mockReturnValue(of(true)); + businessDates.getBusinessDates.mockReturnValue( (options.businessDate === 'fails' ? throwError(() => new Error('down')) : of(BUSINESS_DATE)) as never, @@ -113,7 +130,7 @@ describe('LoanDelinquencyTabComponent', () => { ...adapters.providers, provideTestConfig({ rbacEnabled: true }), { provide: LoansService, useValue: loansService }, - { provide: BusinessDateManagementService, useValue: businessDates }, + { provide: BUSINESS_DATE_API, useValue: businessDates }, { provide: DialogService, useValue: dialogs }, { provide: NotificationService, useValue: notifications }, { provide: AuthService, useValue: auth }, @@ -227,11 +244,14 @@ describe('LoanDelinquencyTabComponent', () => { expect(button(RESUME_BUTTON)).toBeNull(); }); - it('still offers pause, but not resume, when the business date could not be read', async () => { - await setup({ businessDate: 'fails' }); + it('still offers both actions when the business date could not be read', async () => { + // The tab dates by the platform's today, which is the business date when one governs and + // the platform's clock when none does. Withholding Resume from a tenant that is not using + // the business-date feature would take the action away from most of them. + await setup({ businessDate: 'fails', periods: [ENDED, pauseCoveringToday()] }); expect(button(PAUSE_BUTTON)).not.toBeNull(); - expect(button(RESUME_BUTTON)).toBeNull(); + expect(button(RESUME_BUTTON)).not.toBeNull(); // Its failure is not the tables' failure. expect(component.hasError()).toBe(false); }); @@ -294,14 +314,15 @@ describe('LoanDelinquencyTabComponent', () => { expect(component.isActing()).toBe(false); }); - it('opens the dialog with no start when the business date is not known', async () => { + it('opens the dialog at the platform clock when no business date governs', async () => { await setup({ businessDate: 'fails' }); dialogs.open.mockResolvedValue(undefined); await component.onPause(); + // A real date, not `undefined`: the dialog's bound is "today as the platform sees it". expect(dialogs.open).toHaveBeenCalledWith(expect.any(Function), { - data: { businessDate: undefined }, + data: { businessDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }, }); }); }); @@ -339,13 +360,20 @@ describe('LoanDelinquencyTabComponent', () => { expect(changed).not.toHaveBeenCalled(); }); - it('does not ask, or send, without a business date to date it by', async () => { - await setup({ businessDate: 'fails' }); + it('dates a resume by the platform clock when no business date governs', async () => { + await setup({ businessDate: 'fails', periods: [ENDED, pauseCoveringToday()] }); + dialogs.confirm.mockResolvedValue(true); await component.onResume(); - expect(dialogs.confirm).not.toHaveBeenCalled(); - expect(loansService.postLoansLoanIdDelinquencyActions).not.toHaveBeenCalled(); + expect(dialogs.confirm).toHaveBeenCalled(); + const [, body] = loansService.postLoansLoanIdDelinquencyActions.mock.lastCall as [ + number, + { action: string; startDate: string }, + ]; + expect(body.action).toBe('resume'); + // Fineract's `dd MMMM yyyy`, so a real date reached the platform rather than nothing. + expect(body.startDate).toMatch(/^\d{2} \w+ \d{4}$/); }); }); }); diff --git a/src/app/features/loans/tabs/loan-delinquency-tab.component.ts b/src/app/features/loans/tabs/loan-delinquency-tab.component.ts index 481b4e27f..3704f94aa 100644 --- a/src/app/features/loans/tabs/loan-delinquency-tab.component.ts +++ b/src/app/features/loans/tabs/loan-delinquency-tab.component.ts @@ -29,12 +29,8 @@ import { HasPermissionDirective, } from '../../../shared'; import { I18N, TranslatePipe } from '../../../core/adapters'; -import { - BusinessDateManagementService, - GetLoansLoanIdDelinquencySummary, - LoansService, -} from '../../../api'; -import { skipErrorToast } from '../../../core/http/http-context'; +import { GetLoansLoanIdDelinquencySummary, LoansService } from '../../../api'; +import { PlatformDateService } from '../../../core/services/platform-date.service'; import { DialogService } from '../../../core/services/dialog.service'; import { NotificationService } from '../../../core/services/notification.service'; import { @@ -233,7 +229,7 @@ export class LoanDelinquencyTabComponent implements OnInit { readonly changed = output(); private readonly loansService = inject(LoansService); - private readonly businessDateService = inject(BusinessDateManagementService); + private readonly platformDate = inject(PlatformDateService); private readonly dialogs = inject(DialogService); private readonly notifications = inject(NotificationService); private readonly i18n = inject(I18N); @@ -245,13 +241,22 @@ export class LoanDelinquencyTabComponent implements OnInit { /** A pause or resume request is in flight; both buttons wait for it. */ readonly isActing = signal(false); - /** The platform's business date as `YYYY-MM-DD`, once known. */ - readonly businessDate = signal(undefined); + /** + * The date this tab treats as now, `YYYY-MM-DD`: the business date when one governs, and the + * platform's own clock otherwise. + * + * Both halves matter. A resume has to be dated a day the platform accepts as current, and the + * stored business date is not that day on a tenant with the feature switched off — those rows + * persist and stop being maintained (#758). Equally, withholding Resume from such a tenant + * because it has no business date would remove the action from everyone not using the feature, + * which is most of them. + */ + readonly platformToday = signal(undefined); /** Pause periods ride along on the summary rather than being a resource of their own. */ readonly pausePeriods = computed(() => this.summary()?.delinquencyPausePeriods ?? []); - readonly resumable = computed(() => canResume(this.pausePeriods(), this.businessDate())); + readonly resumable = computed(() => canResume(this.pausePeriods(), this.platformToday())); tagColumns: ColumnDef[] = [ { key: 'classification', label: 'LOANS.CLASSIFICATION' }, @@ -282,15 +287,12 @@ export class LoanDelinquencyTabComponent implements OnInit { * without a date the tab simply does not offer Resume. */ private loadBusinessDate(): void { - this.businessDateService - .getBusinessdate('body', false, { context: skipErrorToast() }) - .subscribe({ - next: (dates) => { - const business = dates.find(({ type }) => type === 'BUSINESS_DATE'); - this.businessDate.set(toIsoDay(business?.date) || undefined); - }, - error: () => undefined, - }); + // Through `PlatformDateService`, which answers the business date when one governs and the + // platform's clock when none does — see `platformToday`. + this.platformDate.resolveToday().subscribe({ + next: (today) => this.platformToday.set(today), + error: () => undefined, + }); } async onPause(): Promise { @@ -298,7 +300,9 @@ export class LoanDelinquencyTabComponent implements OnInit { await import('./loan-delinquency-pause-dialog.component'); const period = await this.dialogs.open( LoanDelinquencyPauseDialogComponent, - { data: { businessDate: this.businessDate() } }, + // The dialog's field is still called `businessDate`: it is the bound it enforces, and on a + // tenant using the feature that is exactly what this is. + { data: { businessDate: this.platformToday() } }, ); if (!period) return; @@ -313,7 +317,7 @@ export class LoanDelinquencyTabComponent implements OnInit { } async onResume(): Promise { - const businessDate = this.businessDate(); + const businessDate = this.platformToday(); if (!businessDate) return; const confirmed = await this.dialogs.confirm({ diff --git a/src/app/features/products/deposit-account-view.component.test.ts b/src/app/features/products/deposit-account-view.component.test.ts index b7d9059a1..1915f510e 100644 --- a/src/app/features/products/deposit-account-view.component.test.ts +++ b/src/app/features/products/deposit-account-view.component.test.ts @@ -20,7 +20,7 @@ import { createSpyObj, SpyObj } from '../../testing/mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { provideHttpClient } from '@angular/common/http'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { of, throwError } from 'rxjs'; @@ -404,7 +404,7 @@ describe('DepositAccountViewComponent', () => { component.loadData(); expect(component.transactions()).toEqual([]); - expect(component.hasError()).toBe(false); + expect(component.loadError()).toBeNull(); expect(component.deposit()).toBeTruthy(); }); }); @@ -478,7 +478,7 @@ describe('DepositAccountViewComponent', () => { await setup(account({ id: 300, value: 'Active', active: true })); expect(component.charges()).toEqual([]); - expect(component.hasError()).toBe(false); + expect(component.loadError()).toBeNull(); }); }); @@ -565,7 +565,34 @@ describe('DepositAccountViewComponent', () => { expect(component.chartRows()).toEqual([]); // The account read failing *is* an error for this screen, unlike the separate reads. - expect(component.hasError()).toBe(true); + expect(component.loadError()).toBe('failed'); + }); + + it('offers a retry on a failed read, and nothing to retry on a 404', async () => { + // The screen used to render the breadcrumb and an empty page either way — it held a + // `hasError` boolean that no part of the template read. + await setup(account({ id: 300, value: 'Active', active: true })); + deposits['get'].mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })) as never, + ); + component.loadData(); + fixture.detectChanges(); + expect(component.loadError()).toBe('failed'); + expect( + fixture.nativeElement.querySelector('[data-testid="deposit-load-error-action"]'), + ).not.toBeNull(); + + deposits['get'].mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 404 })) as never, + ); + component.loadData(); + fixture.detectChanges(); + // A 404 or 403 cannot be retried, so the action goes back instead of repeating a request + // that will fail again. + expect(component.loadError()).toBe('not-found'); + expect( + fixture.nativeElement.querySelector('[data-testid="deposit-load-error"]'), + ).not.toBeNull(); }); }); diff --git a/src/app/features/products/deposit-account-view.component.ts b/src/app/features/products/deposit-account-view.component.ts index e95ddf475..7dea71d4e 100644 --- a/src/app/features/products/deposit-account-view.component.ts +++ b/src/app/features/products/deposit-account-view.component.ts @@ -30,6 +30,9 @@ import { ActivatedRoute, Router } from '@angular/router'; import { from } from 'rxjs'; import { DecimalPipe, NgClass } from '@angular/common'; import { StatusBadgeComponent } from '../../shared/components/status-badge/status-badge.component'; +import { LoadErrorComponent } from '../../shared'; +import { SpinnerComponent } from '../../ui/spinner/spinner.component'; +import { HttpErrorResponse } from '@angular/common/http'; import { EntityDatatablesComponent } from '../../shared/components/entity-datatables/entity-datatables.component'; import { SavingsStandingInstructionsTabComponent } from './savings/savings-standing-instructions-tab.component'; import { DEPOSIT_ACCOUNT_API, I18N, TranslatePipe } from '../../core/adapters'; @@ -111,9 +114,34 @@ export type DepositTab = (typeof DEPOSIT_TAB)[keyof typeof DEPOSIT_TAB]; IonList, IonItem, RequiresPermissionDirective, + LoadErrorComponent, + SpinnerComponent, ], template: ` - @if (deposit()) { + @if (loadError() === 'not-found') { +
+ +
+ } @else if (loadError() === 'failed') { +
+ +
+ } @else if (isLoading()) { +
+ +
+ } @else if (deposit()) {
@@ -750,7 +778,14 @@ export class DepositAccountViewComponent implements OnInit, OnDestroy { readonly chartRows = signal([]); readonly expandedSlabIndex = signal(null); readonly isLoading = signal(true); - readonly hasError = signal(false); + /** + * Why the account is not on screen: absent, or the read failed. `null` once it is loaded. + * + * This replaces a `hasError` boolean that nothing rendered — the template was wrapped in + * `@if (deposit())`, so a failed read showed the breadcrumb and an empty page, which is the + * savings view's #763 on this screen. Two states, because a 404 or 403 cannot be retried. + */ + readonly loadError = signal<'not-found' | 'failed' | null>(null); readonly transactionColumns = ['id', 'date', 'type', 'amount', 'runningBalance', 'actions']; readonly chargeColumns = ['name', 'amount', 'outstanding']; @@ -788,13 +823,17 @@ export class DepositAccountViewComponent implements OnInit, OnDestroy { this.deposit.set(account); this.charges.set([...account.charges]); this.chartRows.set(account.chartRows); - this.hasError.set(false); + this.loadError.set(null); this.isLoading.set(false); this.loadTransactions(); this.loadChargeOptions(); }, - error: () => { - this.hasError.set(true); + // The interceptor's toast still fires here, unlike on the savings view: this read goes + // through `DEPOSIT_ACCOUNT_API`, which takes no per-request context, and suppressing it + // inside the adapter would also silence the deposit *form*, which reports nothing of its + // own. A toast beside the panel is a duplicate report; a silent form is a blank screen. + error: (error: HttpErrorResponse) => { + this.loadError.set(error.status === 404 || error.status === 403 ? 'not-found' : 'failed'); this.isLoading.set(false); }, }); diff --git a/src/app/features/products/fixed-deposits/fixed-deposit-form.component.test.ts b/src/app/features/products/fixed-deposits/fixed-deposit-form.component.test.ts index 32164a318..765438602 100644 --- a/src/app/features/products/fixed-deposits/fixed-deposit-form.component.test.ts +++ b/src/app/features/products/fixed-deposits/fixed-deposit-form.component.test.ts @@ -20,7 +20,7 @@ import { createSpyObj, SpyObj } from '../../../testing/mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FixedDepositAccountFormComponent } from './fixed-deposit-form.component'; -import { DEPOSIT_ACCOUNT_API } from '../../../core/adapters'; +import { BUSINESS_DATE_API, DEPOSIT_ACCOUNT_API } from '../../../core/adapters'; import { mapDepositAccount } from '../../../core/adapters/api/fineract-deposit-account.api'; import { Observable, of, throwError } from 'rxjs'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; @@ -142,6 +142,16 @@ describe('FixedDepositAccountFormComponent', () => { await TestBed.configureTestingModule({ imports: [FixedDepositAccountFormComponent], providers: [ + { + // A tenant without the business-date feature: `PlatformDateService` then seeds this + // form's dates from the platform's clock and asks the backend nothing. Without it the + // service reaches the generated client, and the request would need answering here. + provide: BUSINESS_DATE_API, + useValue: { + getBusinessDates: () => of([]), + isBusinessDateEnabled: () => of(false), + }, + }, ...provideTranslateTesting(), provideNoopAnimations(), { provide: DEPOSIT_ACCOUNT_API, useValue: deposits }, diff --git a/src/app/features/products/fixed-deposits/fixed-deposit-form.component.ts b/src/app/features/products/fixed-deposits/fixed-deposit-form.component.ts index b258abd26..e5946afc7 100644 --- a/src/app/features/products/fixed-deposits/fixed-deposit-form.component.ts +++ b/src/app/features/products/fixed-deposits/fixed-deposit-form.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { PlatformDateService } from '../../../core/services/platform-date.service'; import { FormsModule } from '@angular/forms'; import { DEPOSIT_ACCOUNT_API, TranslatePipe } from '../../../core/adapters'; import { ClientSearchComponent } from '../../../shared/components/client-search/client-search.component'; @@ -423,6 +424,7 @@ export class FixedDepositAccountFormComponent implements OnInit { /** Activated route for editing */ private readonly deposits = inject(DEPOSIT_ACCOUNT_API); private readonly route = inject(ActivatedRoute); + private readonly platformDate = inject(PlatformDateService); /** Path for redirection */ private readonly LIST_PATH = '/products/fixed-deposits'; @@ -462,6 +464,7 @@ export class FixedDepositAccountFormComponent implements OnInit { * Component initialization. */ ngOnInit(): void { + this.seedPlatformDates(); this.route.queryParams.subscribe((params) => { const clientId = params['clientId']; if (clientId) { @@ -482,6 +485,22 @@ export class FixedDepositAccountFormComponent implements OnInit { }); } + /** + * Seeds the date defaults from the platform, not the browser. + * + * Fineract judges "the future" by its own reckoning — the tenant's timezone, or the business + * date when that feature is on — so a default taken from `new Date()` is refused whenever the + * browser is ahead of it (#758). Only on create: an edit loads the record's own dates and must + * not have them overwritten. + */ + private seedPlatformDates(): void { + if (this.isEditMode()) return; + this.platformDate.resolveToday().subscribe((today) => { + if (this.isEditMode()) return; + this.submittedOnDate.set(today); + }); + } + getClientId(): number | null { return (this.account()['clientId'] as number) || null; } diff --git a/src/app/features/products/recurring-deposits/recurring-deposit-form.component.ts b/src/app/features/products/recurring-deposits/recurring-deposit-form.component.ts index 4484063e2..6eef95273 100644 --- a/src/app/features/products/recurring-deposits/recurring-deposit-form.component.ts +++ b/src/app/features/products/recurring-deposits/recurring-deposit-form.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { PlatformDateService } from '../../../core/services/platform-date.service'; import { FormsModule } from '@angular/forms'; import { TranslatePipe } from '../../../core/adapters'; import { ClientSearchComponent } from '../../../shared/components/client-search/client-search.component'; @@ -379,6 +380,7 @@ export class RecurringDepositAccountFormComponent implements OnInit { private readonly router = inject(Router); /** Activated route for editing */ private readonly route = inject(ActivatedRoute); + private readonly platformDate = inject(PlatformDateService); private readonly notifications = inject(NotificationService); /** Base path for redirection */ @@ -407,6 +409,7 @@ export class RecurringDepositAccountFormComponent implements OnInit { * Component initialization. */ ngOnInit(): void { + this.seedPlatformDates(); this.route.queryParams.subscribe((params) => { const clientId = params['clientId']; if (clientId) { @@ -427,6 +430,22 @@ export class RecurringDepositAccountFormComponent implements OnInit { }); } + /** + * Seeds the date defaults from the platform, not the browser. + * + * Fineract judges "the future" by its own reckoning — the tenant's timezone, or the business + * date when that feature is on — so a default taken from `new Date()` is refused whenever the + * browser is ahead of it (#758). Only on create: an edit loads the record's own dates and must + * not have them overwritten. + */ + private seedPlatformDates(): void { + if (this.isEditMode()) return; + this.platformDate.resolveToday().subscribe((today) => { + if (this.isEditMode()) return; + this.submittedOnDate.set(today); + }); + } + getClientId(): number | null { return (this.account()['clientId'] as number) || null; } diff --git a/src/app/features/products/savings-account-form.component.test.ts b/src/app/features/products/savings-account-form.component.test.ts new file mode 100644 index 000000000..b60c7c338 --- /dev/null +++ b/src/app/features/products/savings-account-form.component.test.ts @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of } from 'rxjs'; + +import { SavingsAccountFormComponent } from './savings-account-form.component'; +import { provideIonicTesting } from '../../testing/ionic-testing'; +import { provideTranslateTesting } from '../../testing/i18n-testing'; +import { BUSINESS_DATE_API } from '../../core/adapters'; + +/** + * A tenant without the business-date feature, so `PlatformDateService` resolves today from the + * platform's clock and makes no request of its own — these cases are about the rate, and an + * unflushed configuration read would fail them for an unrelated reason. + */ +const NO_BUSINESS_DATE = { + provide: BUSINESS_DATE_API, + useValue: { getBusinessDates: () => of([]), isBusinessDateEnabled: () => of(false) }, +}; + +/** + * #761. Every savings account opened through this form earned 0%. + * + * The form started its Interest Rate field at `0` and sent it on every submit. Fineract reads a + * rate in the request as an account-level override, so the product's rate was discarded — a 5% + * product opened an account paying nothing, and the depositor would only find out at interest + * posting. + * + * These assert the **payload**, through `HttpTestingController`, because that is where the defect + * was: the rendered form looked the same either way. The fixtures are what + * `GET /savingsproducts` actually answers — a list row carries the product's own + * `nominalAnnualInterestRate`, measured as 3.75 for a product configured at 3.75%. + */ +describe('SavingsAccountFormComponent interest rate', () => { + let fixture: ComponentFixture; + let component: SavingsAccountFormComponent; + let http: HttpTestingController; + + const PRODUCT = { + id: 42, + name: 'Probe Savings 3.75%', + shortName: 'P375', + nominalAnnualInterestRate: 3.75, + currency: { code: 'USD', decimalPlaces: 2 }, + }; + + async function setup(): Promise { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [SavingsAccountFormComponent], + providers: [ + provideNoopAnimations(), + provideIonicTesting(), + ...provideTranslateTesting(), + provideHttpClient(), + provideHttpClientTesting(), + NO_BUSINESS_DATE, + { provide: Router, useValue: { navigate: () => undefined, url: '' } }, + { + provide: ActivatedRoute, + useValue: { queryParams: of({}), paramMap: of({ get: () => null }) }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SavingsAccountFormComponent); + component = fixture.componentInstance; + http = TestBed.inject(HttpTestingController); + fixture.detectChanges(); + + // The product list the form loads on init — and the only source the rate prefill needs. + http + .expectOne((request) => request.method === 'GET' && request.url.endsWith('/savingsproducts')) + .flush([PRODUCT]); + fixture.detectChanges(); + } + + /** The body of the create request, after a submit. */ + function submitted(): Record { + const request = http.expectOne( + (candidate) => candidate.method === 'POST' && candidate.url.endsWith('/savingsaccounts'), + ); + const body = request.request.body as Record; + request.flush({ resourceId: 1 }); + return body; + } + + it('takes the rate from the product the user chose', async () => { + await setup(); + + component.onProductChanged(42); + + expect(component.interestRate()).toBe(3.75); + }); + + it('sends the product rate, so the account is not opened at zero', async () => { + await setup(); + component.onProductChanged(42); + + component.onSubmit(); + + expect(submitted()['nominalAnnualInterestRate']).toBe(3.75); + }); + + it('omits the rate entirely when no product rate is known', async () => { + // Omission is how the platform is asked to apply the product's own rate. Sending `0` is the + // bug: it is a valid override, and it overrides with nothing. + await setup(); + + component.onSubmit(); + + const body = submitted(); + expect('nominalAnnualInterestRate' in body).toBe(false); + }); + + it('keeps a rate the user typed, including a deliberate zero', async () => { + // 0% is a rate an institution may mean, so it cannot double as "not filled in" — which is + // why the field starts empty rather than at zero. + await setup(); + component.onProductChanged(42); + + component.onInterestRateChanged(0); + component.onSubmit(); + + expect(submitted()['nominalAnnualInterestRate']).toBe(0); + }); + + it('treats a cleared field as not stated, rather than as zero', async () => { + await setup(); + component.onProductChanged(42); + + component.onInterestRateChanged(''); + + expect(component.interestRate()).toBeNull(); + component.onSubmit(); + expect('nominalAnnualInterestRate' in submitted()).toBe(false); + }); + + it('prefills the rate when the product was chosen before the list arrived', async () => { + // A client-scoped route pre-selects the product id, and the list lands afterwards. + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [SavingsAccountFormComponent], + providers: [ + provideNoopAnimations(), + provideIonicTesting(), + ...provideTranslateTesting(), + provideHttpClient(), + provideHttpClientTesting(), + NO_BUSINESS_DATE, + { provide: Router, useValue: { navigate: () => undefined, url: '' } }, + { + provide: ActivatedRoute, + useValue: { queryParams: of({ clientId: '7' }), paramMap: of({ get: () => null }) }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SavingsAccountFormComponent); + component = fixture.componentInstance; + http = TestBed.inject(HttpTestingController); + fixture.detectChanges(); + component.account.update((account) => ({ ...account, productId: 42 })); + + http + .expectOne((request) => request.method === 'GET' && request.url.endsWith('/savingsproducts')) + .flush([PRODUCT]); + + expect(component.interestRate()).toBe(3.75); + + // The client search reads the pre-selected client; answered so the verify below is clean. + for (const request of http.match((candidate) => candidate.url.includes('/clients/'))) { + request.flush({ id: 7, displayName: 'Probe Client' }); + } + }); + + afterEach(() => http.verify()); +}); diff --git a/src/app/features/products/savings-account-form.component.ts b/src/app/features/products/savings-account-form.component.ts index bdd7a0f07..188a97717 100644 --- a/src/app/features/products/savings-account-form.component.ts +++ b/src/app/features/products/savings-account-form.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { PlatformDateService } from '../../core/services/platform-date.service'; import { FormsModule } from '@angular/forms'; import { TranslatePipe } from '../../core/adapters'; import { ClientSearchComponent } from '../../shared/components/client-search/client-search.component'; @@ -134,7 +135,8 @@ import { createPickersReady } from '../../shared/utils/pickers-ready'; [attr.aria-label]="'COMMON.PRODUCT' | appTranslate" interface="popover" name="productId" - [(ngModel)]="account().productId" + [ngModel]="account().productId" + (ngModelChange)="onProductChanged($event)" required [disabled]="isEditMode()" > @@ -186,8 +188,9 @@ import { createPickersReady } from '../../shared/utils/pickers-ready'; [attr.aria-label]="'COMMON.INTEREST_RATE' | appTranslate" type="number" name="nominalAnnualInterestRate" + data-testid="savings-interest-rate" [ngModel]="interestRate()" - (ngModelChange)="interestRate.set($event)" + (ngModelChange)="onInterestRateChanged($event)" >
@@ -250,6 +253,7 @@ export class SavingsAccountFormComponent implements OnInit { private readonly productService = inject(SavingsProductService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly platformDate = inject(PlatformDateService); private readonly notifications = inject(NotificationService); private readonly mutationOutcome = inject(MutationOutcomeService); private readonly i18n = inject(I18N); @@ -261,12 +265,20 @@ export class SavingsAccountFormComponent implements OnInit { readonly isSaving = signal(false); readonly account = signal({}); - /** Interest rate bound separately as it's missing from model */ - readonly interestRate = signal(0); + /** + * The account's interest rate, bound separately because the generated request model omits it. + * + * `null` means "not stated", and is what the form starts at — not `0`. Fineract reads a rate + * in the request as an **account-level override** of the product's, so a form that always sent + * `nominalAnnualInterestRate: 0` opened every savings account at 0% however the product was + * configured. That is issue #761, and the depositor only finds out at interest posting. + */ + readonly interestRate = signal(null); readonly submittedOnDate = signal(toIsoDate(new Date())); readonly products = signal([]); ngOnInit(): void { + this.seedPlatformDates(); this.loadProducts(); // Check for clientId in query params for pre-population @@ -287,6 +299,22 @@ export class SavingsAccountFormComponent implements OnInit { }); } + /** + * Seeds the date defaults from the platform, not the browser. + * + * Fineract judges "the future" by its own reckoning — the tenant's timezone, or the business + * date when that feature is on — so a default taken from `new Date()` is refused whenever the + * browser is ahead of it (#758). Only on create: an edit loads the record's own dates and must + * not have them overwritten. + */ + private seedPlatformDates(): void { + if (this.isEditMode()) return; + this.platformDate.resolveToday().subscribe((today) => { + if (this.isEditMode()) return; + this.submittedOnDate.set(today); + }); + } + onCreateClient() { this.router.navigate(['/clients/create']); } @@ -299,11 +327,42 @@ export class SavingsAccountFormComponent implements OnInit { this.productService.getSavingsproducts().subscribe({ next: (data: GetSavingsProductsResponse[]) => { this.products.set(data || []); + // The product may have been chosen before its list arrived — a client-scoped route + // carries `clientId`, and nothing stops the user picking a product first either. + this.prefillRateFromProduct(this.account().productId); }, error: () => this.notifications.error('Operation failed. Please try again.'), }); } + /** + * Chooses a product, and takes its interest rate with it. + * + * The rate comes out of the product list this form has already loaded: `GET /savingsproducts` + * carries each product's `nominalAnnualInterestRate` — measured, a product configured at 3.75% + * reports 3.75 there — so no extra request is needed. (The *deposit* product lists are the + * opposite case, reporting `0` for every product because a term deposit's rate lives in its + * chart; see `DepositProductSummary`.) + */ + onProductChanged(productId: number | undefined): void { + this.account.update((account) => ({ ...account, productId })); + this.prefillRateFromProduct(productId); + } + + /** A rate the user types stands, including a deliberate 0%, which is a rate an institution may mean. */ + onInterestRateChanged(value: unknown): void { + this.interestRate.set( + value === '' || value === null || value === undefined ? null : Number(value), + ); + } + + private prefillRateFromProduct(productId: number | undefined): void { + if (productId === undefined || productId === null) return; + const product = this.products().find((candidate) => candidate.id === productId); + if (!product) return; + this.interestRate.set(product.nominalAnnualInterestRate ?? null); + } + private loadAccountData(): void { if (!this.accountId) return; this.savingsService.getSavingsaccountsAccountId(this.accountId).subscribe({ @@ -316,7 +375,9 @@ export class SavingsAccountFormComponent implements OnInit { clientId: data.clientId, productId: data.savingsProductId, }); - this.interestRate.set(data.nominalAnnualInterestRate || 0); + // `?? null`, not `|| 0`: an account deliberately opened at 0% must stay at 0% when it + // is edited, and an account with no override must not acquire one. + this.interestRate.set(data.nominalAnnualInterestRate ?? null); }, error: () => this.notifications.error('Operation failed. Please try again.'), }); @@ -329,10 +390,15 @@ export class SavingsAccountFormComponent implements OnInit { this.account().dateFormat = FINERACT_DATE_FORMAT; this.account().locale = FINERACT_LOCALE; - // Cast to Record to add missing properties to the payload + // Cast to Record to add missing properties to the payload. + // + // The rate is sent only when there is one. Fineract treats it as an account-level override, + // so omitting it is how the account is opened at the product's own rate — and sending `0` + // because the form had no value is how every account came to earn nothing (#761). + const rate = this.interestRate(); const payload: Record = { ...this.account(), - nominalAnnualInterestRate: this.interestRate(), + ...(rate === null ? {} : { nominalAnnualInterestRate: rate }), }; if (this.isEditMode() && this.accountId) { diff --git a/src/app/features/products/savings-account-view.component.test.ts b/src/app/features/products/savings-account-view.component.test.ts index eb2013152..cd3f6bf97 100644 --- a/src/app/features/products/savings-account-view.component.test.ts +++ b/src/app/features/products/savings-account-view.component.test.ts @@ -25,7 +25,8 @@ import { SavingsAccountService } from '../../api'; import { AuthService } from '../../core/services/auth.service'; import { DialogService } from '../../core/services/dialog.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { signal } from '@angular/core'; import { provideIonicTesting } from '../../testing/ionic-testing'; @@ -108,13 +109,69 @@ describe('SavingsAccountViewComponent', () => { }); it('should load savings details on init', () => { + // `associations=all` is what brings the transactions and charges with the account; the + // trailing arguments are the generated signature's `observe`, `reportProgress` and options — + // the last carrying `skipErrorToast`, because this screen reports a failure itself. expect(savingsServiceSpy.getSavingsaccountsAccountId).toHaveBeenCalledWith( 789, false, undefined, 'all', + 'body', + false, + expect.objectContaining({ context: expect.anything() }), ); expect(component.account()?.savingsProductName).toBe('Regular Savings'); + expect(component.loadError()).toBeNull(); + expect(component.isLoading()).toBe(false); + }); + + /** + * #763. A failed account read rendered the breadcrumb and an empty page: no message, no retry, + * no spinner, so staff could not tell a missing account from a broken request. + */ + describe('when the account cannot be loaded', () => { + function failWith(status: number): void { + savingsServiceSpy.getSavingsaccountsAccountId.mockReturnValue( + throwError(() => new HttpErrorResponse({ status })) as never, + ); + component.loadAccountData(); + fixture.detectChanges(); + } + + it('shows the shared error state with a working retry', () => { + failWith(500); + + expect(component.loadError()).toBe('failed'); + const panel = fixture.nativeElement.querySelector('[data-testid="savings-load-error"]'); + expect(panel).not.toBeNull(); + expect(panel.getAttribute('role')).toBe('alert'); + + const retry = fixture.nativeElement.querySelector( + '[data-testid="savings-load-error-action"]', + ); + expect(retry).not.toBeNull(); + + // And the retry actually re-reads: the count goes up when it is pressed. + const before = savingsServiceSpy.getSavingsaccountsAccountId.mock.calls.length; + retry.click(); + expect(savingsServiceSpy.getSavingsaccountsAccountId.mock.calls.length).toBe(before + 1); + }); + + it('offers no retry for a 404 or a 403, which cannot succeed on a second try', () => { + failWith(404); + expect(component.loadError()).toBe('not-found'); + + failWith(403); + expect(component.loadError()).toBe('not-found'); + }); + + it('stops showing the spinner once the read has failed', () => { + failWith(500); + + expect(component.isLoading()).toBe(false); + expect(fixture.nativeElement.querySelector('[data-testid="savings-loading"]')).toBeNull(); + }); }); it('renders the overview labels through the translation adapter', () => { diff --git a/src/app/features/products/savings-account-view.component.ts b/src/app/features/products/savings-account-view.component.ts index cadd4df4c..e01916d1c 100644 --- a/src/app/features/products/savings-account-view.component.ts +++ b/src/app/features/products/savings-account-view.component.ts @@ -37,12 +37,19 @@ import { SavingsAccountTransactionData, SavingsAccountChargeData, } from '../../api'; -import { StatusBadgeComponent, RequiresPermissionDirective } from '../../shared'; +import { + StatusBadgeComponent, + RequiresPermissionDirective, + LoadErrorComponent, +} from '../../shared'; +import { SpinnerComponent } from '../../ui/spinner/spinner.component'; import { EntityNotesComponent } from '../../shared/components/entity-notes/entity-notes.component'; import { EntityDocumentsComponent } from '../../shared/components/entity-documents/entity-documents.component'; import { EntityDatatablesComponent } from '../../shared/components/entity-datatables/entity-datatables.component'; import { SavingsStandingInstructionsTabComponent } from './savings/savings-standing-instructions-tab.component'; import { NotificationService } from '../../core/services/notification.service'; +import { HttpErrorResponse } from '@angular/common/http'; +import { skipErrorToast } from '../../core/http/http-context'; import { DialogService } from '../../core/services/dialog.service'; import { SavingsBlockDialogComponent, @@ -110,6 +117,8 @@ export type SavingsTab = (typeof SAVINGS_TAB)[keyof typeof SAVINGS_TAB]; EntityNotesComponent, EntityDocumentsComponent, EntityDatatablesComponent, + LoadErrorComponent, + SpinnerComponent, SavingsStandingInstructionsTabComponent, RouterModule, TranslatePipe, @@ -134,7 +143,30 @@ export type SavingsTab = (typeof SAVINGS_TAB)[keyof typeof SAVINGS_TAB]; TooltipDirective, ], template: ` - @if (account()) { + @if (loadError() === 'not-found') { +
+ +
+ } @else if (loadError() === 'failed') { +
+ +
+ } @else if (isLoading()) { +
+ +
+ } @else if (account()) {
@@ -859,6 +891,14 @@ export class SavingsAccountViewComponent implements OnInit, OnDestroy { private readonly i18n = inject(I18N); accountId = 0; + /** + * Why the account is not on screen: absent, or the read failed. `null` once it is loaded. + * + * Two states rather than one because the answers differ: a 404 or 403 cannot be retried, and + * offering Try again on one is offering a button that will fail again. + */ + readonly loadError = signal<'not-found' | 'failed' | null>(null); + readonly isLoading = signal(true); readonly account = signal(null); readonly transactions = signal([]); readonly charges = signal([]); @@ -905,16 +945,35 @@ export class SavingsAccountViewComponent implements OnInit, OnDestroy { } } + /** + * Reads the account. + * + * A failure used to toast and leave the screen blank: the whole template was wrapped in + * `@if (account())`, so a 5xx, an outage or a dropped connection rendered the breadcrumb and + * nothing else, and staff could not tell a missing account from a broken request (#763). + * + * The toast is suppressed here because the page now reports the failure itself — the shared + * error state with a working Try again, which is what the rest of the app does since #223. + * A 404 or 403 gets the other branch: no retry, because repeating it cannot succeed. + */ loadAccountData() { + this.isLoading.set(true); this.savingsService - .getSavingsaccountsAccountId(this.accountId, false, undefined, 'all') + .getSavingsaccountsAccountId(this.accountId, false, undefined, 'all', 'body', false, { + context: skipErrorToast(), + }) .subscribe({ next: (data) => { this.account.set(data); this.transactions.set(data.transactions || []); this.charges.set(data.charges || []); + this.loadError.set(null); + this.isLoading.set(false); + }, + error: (error: HttpErrorResponse) => { + this.loadError.set(error.status === 404 || error.status === 403 ? 'not-found' : 'failed'); + this.isLoading.set(false); }, - error: () => this.notifications.error('Operation failed. Please try again.'), }); } diff --git a/src/app/layout/header.component.test.ts b/src/app/layout/header.component.test.ts index 069168690..f2c612000 100644 --- a/src/app/layout/header.component.test.ts +++ b/src/app/layout/header.component.test.ts @@ -28,7 +28,8 @@ import { ViewportService } from '../core/services/viewport.service'; import { EMPTY, of } from 'rxjs'; import { WritableSignal, signal } from '@angular/core'; import { provideTranslateTesting } from '../testing/i18n-testing'; -import { BusinessDateManagementService } from '../api'; +import { BUSINESS_DATE_API } from '../core/adapters/api/business-date.api'; +import { PlatformDateService } from '../core/services/platform-date.service'; describe('HeaderComponent', () => { let component: HeaderComponent; @@ -67,8 +68,14 @@ describe('HeaderComponent', () => { { provide: AuthService, useValue: authServiceSpy }, { provide: NavigationConfigService, useValue: navigationConfigSpy }, { - provide: BusinessDateManagementService, - useValue: { getBusinessdate: () => of(businessDates) }, + // Through the adapter token, because the header no longer reads `/businessdate` + // itself: `PlatformDateService` asks whether the feature is on first, since the rows + // persist and go stale once it is switched off (#758). + provide: BUSINESS_DATE_API, + useValue: { + getBusinessDates: () => of(businessDates), + isBusinessDateEnabled: () => of(businessDates.length > 0), + }, }, { provide: Router, useValue: routerSpy }, { provide: ViewportService, useValue: { isMobile } }, @@ -93,6 +100,9 @@ describe('HeaderComponent', () => { it('shows the business date when the instance has one', () => { businessDates = [{ type: 'BUSINESS_DATE', date: [2026, 10, 2] }]; + // The platform date is resolved once and shared, and the harness above already created a + // header — so the new stub only takes effect once that resolution is discarded. + TestBed.inject(PlatformDateService).refresh(); fixture = TestBed.createComponent(HeaderComponent); fixture.detectChanges(); diff --git a/src/app/layout/header.component.ts b/src/app/layout/header.component.ts index 1064a7082..47d7e83b2 100644 --- a/src/app/layout/header.component.ts +++ b/src/app/layout/header.component.ts @@ -62,7 +62,8 @@ import { buildBreadcrumbs } from './breadcrumb'; /** The shipped mark, used when the deployment names none of its own. */ const DEFAULT_LOGO = 'favicon.png'; import { ThemeService } from '../core/services/theme.service'; -import { SearchAPIService, GetSearchResponse, BusinessDateManagementService } from '../api'; +import { SearchAPIService, GetSearchResponse } from '../api'; +import { PlatformDateService } from '../core/services/platform-date.service'; import { NavigationConfigService, NavSearchResult, @@ -79,6 +80,20 @@ type HeaderSearchResult = * Contains the branding logo, application title, current user information, * language selector, and the global logout action. */ +/** + * An ISO date as the browser's locale writes it. + * + * Read through its parts rather than handed to `new Date('2026-10-17')`, which ECMAScript reads + * as UTC midnight — west of Greenwich that renders as the day before, which is precisely the + * off-by-one this label is being fixed for. + */ +function localeDate(iso: string | null): string | null { + if (!iso) return null; + const [year, month, day] = iso.split('-').map(Number); + if (!year || !month || !day) return null; + return new Date(year, month - 1, day).toLocaleDateString(); +} + @Component({ selector: 'app-header', standalone: true, @@ -702,7 +717,7 @@ export class HeaderComponent implements OnInit, OnDestroy { private readonly injector = inject(Injector); private readonly searchService = inject(SearchAPIService); private readonly navigationConfig = inject(NavigationConfigService); - private readonly businessDateService = inject(BusinessDateManagementService); + private readonly platformDate = inject(PlatformDateService); private readonly branding = inject(BrandingService); protected readonly viewport = inject(ViewportService); @@ -788,14 +803,12 @@ export class HeaderComponent implements OnInit, OnDestroy { } private loadSystemInfo() { - this.businessDateService.getBusinessdate().subscribe({ - next: (dates) => { - const bd = dates.find((d) => d.type === 'BUSINESS_DATE'); - if (bd && bd.date) { - const d = bd.date as unknown as number[]; - this.businessDate.set(new Date(d[0], d[1] - 1, d[2]).toLocaleDateString()); - } - }, + // Through `PlatformDateService`, which asks whether the feature is on before trusting the + // stored rows. They persist after it is switched off and stop being maintained — measured, a + // tenant with the feature disabled still reported `BUSINESS_DATE 2026-10-17` on the 18th — + // so this label used to announce a date that governed nothing, a day behind the truth. + this.platformDate.resolveToday().subscribe(() => { + this.businessDate.set(localeDate(this.platformDate.getBusinessDate())); }); const updateTime = () => { diff --git a/src/app/testing/adapters.ts b/src/app/testing/adapters.ts index b79ab956e..c4b887515 100644 --- a/src/app/testing/adapters.ts +++ b/src/app/testing/adapters.ts @@ -18,6 +18,11 @@ */ import { Provider, signal } from '@angular/core'; +import { + BUSINESS_DATE_API, + type BusinessDateApi, + type BusinessDateEntry, +} from '../core/adapters/api/business-date.api'; import { Observable, of } from 'rxjs'; import { DOWNLOAD, @@ -220,6 +225,28 @@ export class FakeDownloadAdapter implements DownloadAdapter { } } +/** + * A {@link BusinessDateApi} that answers for a tenant with the feature switched off. + * + * The default, because that is the common tenant: `PlatformDateService` then resolves today + * from the platform's clock, and a component under test that seeds a date gets the browser's + * date rather than reaching for HTTP. Set {@link businessDate} to put a tenant on one. + */ +export class FakeBusinessDateApi implements BusinessDateApi { + /** The governing business date, ISO, or `null` for a tenant without the feature. */ + businessDate: string | null = null; + + getBusinessDates(): Observable { + return of( + this.businessDate === null ? [] : [{ type: 'BUSINESS_DATE', date: this.businessDate }], + ); + } + + isBusinessDateEnabled(): Observable { + return of(this.businessDate !== null); + } +} + /** Every adapter token bound to a fake, plus handles for asserting on them. */ export function provideFakeAdapters(): { providers: Provider[]; @@ -227,11 +254,13 @@ export function provideFakeAdapters(): { i18n: FakeI18nAdapter; storage: FakeStorageAdapter; download: FakeDownloadAdapter; + businessDate: FakeBusinessDateApi; } { const overlay = new FakeOverlayAdapter(); const i18n = new FakeI18nAdapter(); const storage = new FakeStorageAdapter(); const download = new FakeDownloadAdapter(); + const businessDate = new FakeBusinessDateApi(); return { providers: [ @@ -239,10 +268,14 @@ export function provideFakeAdapters(): { { provide: I18N, useValue: i18n }, { provide: STORAGE, useValue: storage }, { provide: DOWNLOAD, useValue: download }, + // Not a UI adapter, but the same reason: every screen that defaults a date now resolves + // it through `PlatformDateService`, and a TestBed should not need HTTP to render a form. + { provide: BUSINESS_DATE_API, useValue: businessDate }, ], overlay, i18n, storage, download, + businessDate, }; } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3af110b61..893273b54 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1067,7 +1067,9 @@ "INTEREST_CALC_DAYS_IN_YEAR": "Interest Calculation Day-in-Year", "TIMELINE_AND_BALANCE": "Timeline & Balance", "ACTIVATED_ON_DATE": "Activated On Date", - "FIELD_OFFICER": "Field Officer" + "FIELD_OFFICER": "Field Officer", + "LOAD_FAILED": "This savings account could not be loaded.", + "NOT_FOUND": "This savings account doesn't exist, or you don't have permission to view it." }, "USERS": { "CREATE": "Create User", @@ -3075,6 +3077,8 @@ "FUND_FROM_SAVINGS_WARNING": "Activating this deposit will transfer the deposit amount out of the chosen savings account. Activation is refused if that account does not hold enough.", "NO_TRANSFER_TARGETS": "This client has no savings account to transfer the proceeds to.", "REINVEST_NOTE": "This deposit will be closed and a new one opened for the maturity amount, awaiting approval.", - "TRANSFER_DESCRIPTION": "Deposit maturity proceeds" + "TRANSFER_DESCRIPTION": "Deposit maturity proceeds", + "LOAD_FAILED": "This deposit account could not be loaded.", + "NOT_FOUND": "This deposit account doesn't exist, or you don't have permission to view it." } } diff --git a/src/assets/i18n/hi.json b/src/assets/i18n/hi.json index 3bd18de4f..c5910af2b 100644 --- a/src/assets/i18n/hi.json +++ b/src/assets/i18n/hi.json @@ -1157,7 +1157,9 @@ "INTEREST_CALC_DAYS_IN_YEAR": "वर्ष में ब्याज गणना के दिन", "TIMELINE_AND_BALANCE": "समयरेखा और शेष राशि", "ACTIVATED_ON_DATE": "सक्रियण की तिथि", - "FIELD_OFFICER": "फील्ड अधिकारी" + "FIELD_OFFICER": "फील्ड अधिकारी", + "LOAD_FAILED": "यह बचत खाता लोड नहीं हो सका।", + "NOT_FOUND": "यह बचत खाता मौजूद नहीं है, या आपके पास इसे देखने की अनुमति नहीं है।" }, "GROUPS": { "ACTIVATE": "समूह सक्रिय करें", @@ -3060,6 +3062,8 @@ "FUND_FROM_SAVINGS_WARNING": "इस जमा को सक्रिय करने पर जमा राशि चुने गए बचत खाते से अंतरित हो जाएगी। यदि उस खाते में पर्याप्त शेष नहीं है तो सक्रियण अस्वीकृत हो जाएगा।", "NO_TRANSFER_TARGETS": "इस ग्राहक का कोई बचत खाता नहीं है जिसमें राशि अंतरित की जा सके।", "REINVEST_NOTE": "यह जमा बंद कर दी जाएगी और परिपक्वता राशि के लिए एक नई जमा खोली जाएगी, जो अनुमोदन की प्रतीक्षा में रहेगी।", - "TRANSFER_DESCRIPTION": "जमा परिपक्वता राशि" + "TRANSFER_DESCRIPTION": "जमा परिपक्वता राशि", + "LOAD_FAILED": "यह जमा खाता लोड नहीं हो सका।", + "NOT_FOUND": "यह जमा खाता मौजूद नहीं है, या आपके पास इसे देखने की अनुमति नहीं है।" } } diff --git a/vitest.config.ts b/vitest.config.ts index d1ce31e96..44aa13a48 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,6 +41,16 @@ export default defineConfig({ * once per file, not per test, so a higher ceiling costs nothing on the passing path. */ testTimeout: 30_000, + /** + * The same ceiling for hooks, because that is where the cost above is usually paid: a suite + * that creates its component in `beforeEach` compiles it there, not in the test body. Vitest + * leaves `hookTimeout` at 10s, which `client-form.component.test.ts` and + * `wc-loan-product-form.component.test.ts` exceed whenever the machine is busy — a wizard + * built from several dozen Ionic components, timing out in a hook that is itself + * synchronous. The failure names an unrelated test and reads like a flake in whatever file + * the suite happened to be running. + */ + hookTimeout: 30_000, server: { deps: { inline: [/@ionic\/angular/, /@ionic\/core/],