diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a6e1c448f..d227e0470 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -81,6 +81,13 @@ The same flow runs in CI via `.github/workflows/e2e.yml`. See
[`DOCS/E2E_TESTING.md`](DOCS/E2E_TESTING.md) for writing specs, and prefer `data-testid`
over element selectors so tests survive markup changes.
+Once the backend above is up, `npm run seed:demo-data` populates it with a representative
+dataset — an office, staff, a center, a group, an active loan, a loan pending approval, a
+savings account, a fixed deposit, a share account, a manual journal entry and two reports —
+for manual sanity testing rather than the narrow fixtures an individual spec builds for
+itself. It prints what it created. This is its own Playwright project (`demo-seed`), so it
+never runs as a side effect of the `backend` project in CI.
+
## UI Components
The UI layer is **Ionic** (`@ionic/angular` v8), configured in `mode: 'md'`.
diff --git a/e2e/demo-data.setup.ts b/e2e/demo-data.setup.ts
new file mode 100644
index 000000000..7a1830cfc
--- /dev/null
+++ b/e2e/demo-data.setup.ts
@@ -0,0 +1,115 @@
+/*
+ * 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.
+ */
+
+/**
+ * Populates a freshly provisioned Fineract instance with a representative dataset for a
+ * human to click through — release-candidate sanity testing, a demo, a screenshot pass —
+ * rather than the narrow fixtures an individual spec builds for itself.
+ *
+ * Composed entirely from the existing seed-api.ts building blocks the specs already use and
+ * trust; this adds no new seeding logic of its own; it only sequences what is already there
+ * into one representative pass and prints what it made.
+ *
+ * Not part of any CI project's default run — this is a standalone project
+ * (`--project=demo-seed`) so it never executes as a side effect of `--project=backend`, and
+ * it does not depend on the `setup` project, so `npm run seed:demo-data` works on its own
+ * against a backend that has had nothing else run against it yet.
+ *
+ * Run with:
+ * npm run seed:demo-data
+ *
+ * or, against a non-default backend:
+ * FINERACT_SERVER_URL=/fineract-provider/api/v1 npm run seed:demo-data
+ */
+
+import { test as setup } from '@playwright/test';
+
+import {
+ createApiContext,
+ ensureReferenceData,
+ seedActiveLoan,
+ seedCenter,
+ seedChartReport,
+ seedClient,
+ seedCollateralProduct,
+ seedFixedDepositAccount,
+ seedGroup,
+ seedLoanCollateralType,
+ seedLoanDatatable,
+ seedManualJournalEntry,
+ seedOffice,
+ seedPendingLoan,
+ seedReportDefinition,
+ seedSavingsAccountWithTransactions,
+ seedShareAccount,
+ seedStaff,
+} from './utils/seed-api';
+
+setup('seed a representative demo dataset', async () => {
+ // Generous: this makes roughly twenty sequential API calls against a backend whose first
+ // requests after boot are already slow while caches warm.
+ setup.setTimeout(180000);
+
+ const api = await createApiContext();
+ try {
+ await ensureReferenceData(api);
+ await seedLoanDatatable(api);
+ await seedCollateralProduct(api);
+ await seedLoanCollateralType(api);
+
+ const office = await seedOffice(api, 'Demo');
+ const staff = await seedStaff(api, 'DemoOfficer');
+ const center = await seedCenter(api, 'Demo Center');
+ const group = await seedGroup(api, 'Demo Group');
+
+ const browsingClient = await seedClient(api, 'DemoBrowse');
+ const activeLoan = await seedActiveLoan(api, 'DemoActive');
+ const pendingLoan = await seedPendingLoan(api, 'DemoQueue');
+ const savings = await seedSavingsAccountWithTransactions(api, 'DemoSavings');
+ const fixedDeposit = await seedFixedDepositAccount(api, 'DemoFixed');
+ const shareAccount = await seedShareAccount(api, 'DemoShares');
+ const journalEntry = await seedManualJournalEntry(api, 'DemoJournal');
+ const chartReport = await seedChartReport(api, 'DemoChart');
+ const reportDefinition = await seedReportDefinition(api, 'DemoReportDef');
+
+ console.log(
+ [
+ '',
+ 'Demo dataset ready. Sign in with the usual local admin credentials and look for:',
+ '',
+ ` Office ${office.officeName} (#${office.officeId})`,
+ ` Staff ${staff.staffName}`,
+ ` Center ${center.centerName} (#${center.centerId}) — pending`,
+ ` Group ${group.groupName} (#${group.groupId}) — no parent center yet`,
+ ` Client ${browsingClient.displayName} (#${browsingClient.clientId})`,
+ ` Active loan ${activeLoan.displayName} — #${activeLoan.loanId}, disbursed`,
+ ` Pending loan ${pendingLoan.clientName} — ${pendingLoan.accountNo}, in the approval queue`,
+ ` Savings account ${savings.clientName} — #${savings.savingsId}, one deposit + one hold`,
+ ` Fixed deposit ${fixedDeposit.clientName} — #${fixedDeposit.accountId}, pending approval`,
+ ` Share account ${shareAccount.clientName} — ${shareAccount.productName}, pending approval`,
+ ` Manual journal entry ${journalEntry.debitAccountName} / ${journalEntry.creditAccountName} — reversible`,
+ ` Chart report ${chartReport.reportName} (Reports > Run Reports)`,
+ ` Report definition ${reportDefinition.reportName} (System > Report Definitions)`,
+ '',
+ ].join('\n'),
+ );
+ } finally {
+ await api.dispose();
+ }
+});
diff --git a/e2e/global-search-nav-shortcuts.spec.ts b/e2e/global-search-nav-shortcuts.spec.ts
index 0c4fecbaa..ab911b4fb 100644
--- a/e2e/global-search-nav-shortcuts.spec.ts
+++ b/e2e/global-search-nav-shortcuts.spec.ts
@@ -122,6 +122,30 @@ test.describe('Global search navigation shortcuts', () => {
await expect(page).toHaveURL('/organization/offices');
});
+ test('keeps the results open across the mousedown that starts a click, so a slow render cannot race the dropdown closed', async ({
+ page,
+ }) => {
+ await login(page);
+
+ await searchBox(page).fill('Offices');
+ await expect(offices(page)).toBeVisible();
+
+ // The bug this pins down: on a slow render, the searchbar's 150ms blur-triggered hide
+ // could fire before a click's mouseup landed, collapsing the list out from under the
+ // click and silently swallowing the navigation. `(mousedown)="$event.preventDefault()"`
+ // on each result stops the searchbar from blurring at all when a result is the click's
+ // target, so the list must still be open here even though the click has not completed.
+ const box = await offices(page).boundingBox();
+ if (!box) throw new Error('offices result not found');
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.down();
+ await page.waitForTimeout(200); // longer than the 150ms hide timeout
+ await expect(results(page)).toBeVisible();
+ await page.mouse.up();
+
+ await expect(page).toHaveURL('/organization/offices');
+ });
+
test('withholds a shortcut the user has no permission to reach', async ({ page }) => {
await login(page, ['READ_CLIENT']);
diff --git a/e2e/guidance-tour-dashboard.spec.ts b/e2e/guidance-tour-dashboard.spec.ts
new file mode 100644
index 000000000..d92bc67ff
--- /dev/null
+++ b/e2e/guidance-tour-dashboard.spec.ts
@@ -0,0 +1,99 @@
+/*
+ * 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.
+ */
+
+/**
+ * Cover for the dashboard tour's second step highlighting the wrong element.
+ *
+ * `targetSelector: 'ul'` on that step matched `document.querySelector('ul')`, which returns
+ * the FIRST `
` in the DOM — the sidebar's own ``, which renders before
+ * the dashboard's main content. The step's copy describes the "System Overview & Status" card
+ * (a `.status-list`), so the tour scrolled to and outlined the sidebar instead of the thing it
+ * was actually talking about. Fixed by pointing the selector at `.status-list` directly.
+ */
+
+import { test, expect } from './fixtures';
+
+const TENANT = 'default';
+const USER = 'mifos';
+const PASSWORD = 'password';
+
+test.beforeEach(async ({ page }) => {
+ await page.route(/\/api\/v1\//, async (route) => {
+ await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
+ });
+ await page.route('**/config.json*', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ fineractApiUrl: '/api/v1', defaultTenant: TENANT }),
+ });
+ });
+ await page.route('**/api/v1/authentication**', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ username: USER,
+ userId: 1,
+ base64EncodedAuthenticationKey: 'YmFzZTY0',
+ authenticated: true,
+ officeId: 1,
+ officeName: 'Head Office',
+ roles: [{ id: 1, name: 'Role', description: 'Role' }],
+ permissions: ['ALL_FUNCTIONS'],
+ }),
+ });
+ });
+ await page.route(/\/api\/v1\/businessdate/, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([{ type: 'BUSINESS_DATE', date: [2026, 8, 16] }]),
+ });
+ });
+
+ await page.goto('/login');
+ await page.locator('#tenantId').fill(TENANT);
+ await page.locator('#username').fill(USER);
+ await page.locator('#password').fill(PASSWORD);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+ await expect(page).toHaveURL('/dashboard');
+});
+
+test('highlights the System Status card, not the sidebar, on the dashboard tour', async ({
+ page,
+}) => {
+ await page.locator('.tour-btn').click();
+ await expect(page.locator('app-guidance-tour')).toBeVisible();
+
+ await page.getByRole('button', { name: 'Next' }).click();
+ // Scoped to the tour card specifically: the dashboard's own cards (Pending Approvals, Loan
+ // Status Distribution, etc.) each have their own ion-card-title too.
+ await expect(page.locator('.guidance-card ion-card-title')).toContainText(
+ 'System Overview & Status',
+ );
+
+ // The sidebar's own nav list must never carry the highlight — that was the bug: the bare
+ // 'ul' selector matched it first because it sits earlier in the DOM than the dashboard.
+ await expect(page.locator('.nav-list')).not.toHaveClass(/guidance-highlight/);
+
+ // The dashboard's System Status card is the thing the step is actually describing, and it
+ // alone should carry the highlight.
+ await expect(page.locator('.status-list')).toHaveClass(/guidance-highlight/);
+});
diff --git a/e2e/rbac-multi-permission.spec.ts b/e2e/rbac-multi-permission.spec.ts
new file mode 100644
index 000000000..b7d0d18b4
--- /dev/null
+++ b/e2e/rbac-multi-permission.spec.ts
@@ -0,0 +1,274 @@
+/*
+ * 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.
+ */
+
+/**
+ * RBAC dimensions `rbac-backend-restricted-user.spec.ts` does not reach: a route that
+ * declares more than one permission code (OR semantics), the ALL_FUNCTIONS_READ shortcut
+ * against Fineract's own permission catalogue rather than a mocked session, a second
+ * real-backend action-level gate distinct from loan repayment, and whether a restricted
+ * session's permissions survive an actual page reload rather than only a fresh login.
+ *
+ * Same shape as that spec for the same reason: every refusal is checked both as "the router
+ * sent them to Access Denied" and as "Fineract itself returned 403", because the guard is
+ * defence-in-depth and showing only the first would invite the wrong conclusion.
+ */
+
+import { test, expect, Page } from './fixtures';
+import { landsOn } from './utils/settled-route';
+import { SERVER_URL, TENANT_ID } from './utils/fineract-login';
+import {
+ createApiContext,
+ ensureReferenceData,
+ generatePassword,
+ seedPendingLoan,
+ seedRestrictedUser,
+ seedSuffix,
+ statusAs,
+ SeededRestrictedUser,
+} from './utils/seed-api';
+
+test.describe.configure({ mode: 'serial', timeout: 180_000 });
+
+/** Signs in as a seeded restricted user rather than as the suite's superuser. */
+async function loginAs(page: Page, user: SeededRestrictedUser): Promise {
+ await page.goto('/login');
+ const serverSelect = page.locator('#serverUrl');
+ await serverSelect.waitFor({ state: 'visible' });
+ const preset = await serverSelect.locator(`option[value="${SERVER_URL}"]`).count();
+ if (preset > 0) {
+ await serverSelect.selectOption(SERVER_URL);
+ } else {
+ await serverSelect.selectOption('custom');
+ await page.locator('#customUrl').fill(SERVER_URL);
+ }
+ await page.locator('#tenantId').fill(TENANT_ID);
+ await page.locator('#username').fill(user.username);
+ await page.locator('#password').fill(user.password);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+ await expect(page.getByRole('navigation', { name: 'Main Navigation' })).toBeVisible({
+ timeout: 30_000,
+ });
+}
+
+test.describe('a route declaring more than one permission code (OR semantics)', () => {
+ // /tasks/work-queues declares data: { permissions: ['READ_LOAN', 'READ_CLIENT'] } with no
+ // permissionsMatchAll — the guard's own OR default, so either code alone must admit.
+ let loanOnly: SeededRestrictedUser;
+ let clientOnly: SeededRestrictedUser;
+ let neither: SeededRestrictedUser;
+
+ test.beforeAll(async () => {
+ const api = await createApiContext();
+ try {
+ loanOnly = await seedRestrictedUser(api, ['READ_LOAN']);
+ clientOnly = await seedRestrictedUser(api, ['READ_CLIENT']);
+ neither = await seedRestrictedUser(api, ['READ_AUDIT']);
+ } finally {
+ await api.dispose();
+ }
+ });
+
+ test('is admitted by either declared code alone', async ({ page }) => {
+ await loginAs(page, loanOnly);
+ expect(await landsOn(page, '/tasks/work-queues')).toBe('/tasks/work-queues');
+ });
+
+ test('is admitted by the other declared code alone', async ({ page }) => {
+ await loginAs(page, clientOnly);
+ expect(await landsOn(page, '/tasks/work-queues')).toBe('/tasks/work-queues');
+ });
+
+ test('is refused when holding neither declared code, by the router and by the backend', async ({
+ page,
+ }) => {
+ await loginAs(page, neither);
+ expect(await landsOn(page, '/tasks/work-queues')).toBe('/forbidden');
+
+ // The screen's own reads are refused too — an OR-admitted route is not itself a grant of
+ // either underlying permission.
+ expect(await statusAs(neither, 'GET', '/loans?limit=1')).toBe(403);
+ expect(await statusAs(neither, 'GET', '/clients?limit=1')).toBe(403);
+ });
+});
+
+test.describe('ALL_FUNCTIONS_READ, against the real Fineract permission catalogue', () => {
+ // The mocked equivalent (all-functions-read-shortcut.spec.ts) proves the client is
+ // self-consistent about this shortcut; this proves Fineract's own semantics agree with it.
+ let readOnlySuperuser: SeededRestrictedUser;
+
+ test.beforeAll(async () => {
+ const api = await createApiContext();
+ try {
+ readOnlySuperuser = await seedRestrictedUser(api, ['ALL_FUNCTIONS_READ']);
+ } finally {
+ await api.dispose();
+ }
+ });
+
+ test('reaches read screens across modules it holds no specific code for', async ({ page }) => {
+ await loginAs(page, readOnlySuperuser);
+ expect(await landsOn(page, '/clients')).toBe('/clients');
+ expect(await landsOn(page, '/loans')).toBe('/loans');
+ expect(await landsOn(page, '/accounting/chart-of-accounts')).toBe(
+ '/accounting/chart-of-accounts',
+ );
+ });
+
+ test('is refused every write screen, and the writes themselves', async ({ page }) => {
+ await loginAs(page, readOnlySuperuser);
+
+ expect(await landsOn(page, '/clients/create')).toBe('/forbidden');
+ expect(await landsOn(page, '/accounting/journal-entries/create')).toBe('/forbidden');
+
+ expect(
+ await statusAs(readOnlySuperuser, 'POST', '/clients', {
+ officeId: 1,
+ firstname: 'Should',
+ lastname: 'NotBeCreated',
+ legalFormId: 1,
+ active: false,
+ locale: 'en',
+ dateFormat: 'dd MMMM yyyy',
+ submittedOnDate: '01 January 2026',
+ }),
+ ).toBe(403);
+ });
+});
+
+test.describe('a restricted session across a real page reload', () => {
+ let restricted: SeededRestrictedUser;
+
+ test.beforeAll(async () => {
+ const api = await createApiContext();
+ try {
+ restricted = await seedRestrictedUser(api, ['READ_CLIENT']);
+ } finally {
+ await api.dispose();
+ }
+ });
+
+ test('keeps the same permission boundary after reloading, not just after a fresh login', async ({
+ page,
+ }) => {
+ await loginAs(page, restricted);
+ expect(await landsOn(page, '/clients')).toBe('/clients');
+
+ await page.reload();
+ await page.locator('.app-container').waitFor({ state: 'visible' });
+
+ // A stale or dropped session here would show up as either an unwanted trip back to
+ // /login (session lost) or as reaching a screen the seeded role never held (session
+ // read back looser than it was granted) — auth.service.spec.ts's "normalizes a dirty
+ // session read back from sessionStorage" case, checked here against a real reload
+ // rather than a constructed storage value.
+ expect(await landsOn(page, '/accounting/chart-of-accounts')).toBe('/forbidden');
+ expect(await landsOn(page, '/clients')).toBe('/clients');
+ expect(await statusAs(restricted, 'GET', '/glaccounts')).toBe(403);
+ });
+});
+
+test.describe('a second real action-level gate, distinct from loan repayment', () => {
+ test('is shown the Approve action disabled and naming what it needs, refused by the backend too', async ({
+ page,
+ }) => {
+ const api = await createApiContext();
+ let approver: SeededRestrictedUser;
+ let loan: { loanId: number };
+ try {
+ await ensureReferenceData(api);
+ loan = await seedPendingLoan(api);
+ // Holds enough to open the loan, but not to approve it.
+ approver = await seedRestrictedUser(api, ['READ_LOAN', 'READ_CLIENT']);
+ } finally {
+ await api.dispose();
+ }
+
+ await loginAs(page, approver);
+ expect(await landsOn(page, `/loans/view/${loan.loanId}`)).toBe(`/loans/view/${loan.loanId}`);
+
+ // getByRole would resolve to Ionic's internal shadow-DOM native , which does not
+ // inherit the host ion-button's `title` attribute — only `aria-label` gets forwarded.
+ // data-testid targets the host directly, same as the existing repayment-action assertion.
+ const approve = page.getByTestId('loan-approve-action');
+ await expect(approve).toBeVisible();
+ await expect(approve).toHaveAttribute('disabled', /.*/);
+ await expect(approve).toHaveAttribute('title', /APPROVE_LOAN/);
+
+ expect(
+ await statusAs(approver, 'POST', `/loans/${loan.loanId}?command=approve`, {
+ locale: 'en',
+ dateFormat: 'dd MMMM yyyy',
+ approvedOnDate: '01 January 2026',
+ }),
+ ).toBe(403);
+ });
+});
+
+test.describe('Security module writes (users, roles), against the real backend', () => {
+ // Not covered anywhere else at this level: rbac-route-protection.spec.ts refuses
+ // /security/users and /security/roles by URL, but only against a mocked session. This is
+ // the audit's own "action-level authorization" list (user management, role management) —
+ // checked here against Fineract's actual permission catalogue instead.
+ let restricted: SeededRestrictedUser;
+
+ test.beforeAll(async () => {
+ const api = await createApiContext();
+ try {
+ restricted = await seedRestrictedUser(api, ['READ_USER', 'READ_ROLE']);
+ } finally {
+ await api.dispose();
+ }
+ });
+
+ test('reaches the list screens but is refused the write screens', async ({ page }) => {
+ await loginAs(page, restricted);
+ expect(await landsOn(page, '/security/users')).toBe('/security/users');
+ expect(await landsOn(page, '/security/roles')).toBe('/security/roles');
+ expect(await landsOn(page, '/security/users/create')).toBe('/forbidden');
+ expect(await landsOn(page, `/security/roles/edit/${restricted.roleId}`)).toBe('/forbidden');
+ });
+
+ test('is refused creating a user and modifying a role, by the backend itself', async () => {
+ // Generated rather than written down, for the same reason seedRestrictedUser's own
+ // password is: a literal here would be a credential-shaped string sitting in the tree,
+ // and the request is refused before Fineract ever looks at this field.
+ const throwawayPassword = generatePassword();
+ expect(
+ await statusAs(restricted, 'POST', '/users', {
+ username: `e2eShouldNotExist${seedSuffix()}`,
+ firstname: 'Should',
+ lastname: 'NotBeCreated',
+ email: 'should-not-be-created@example.invalid',
+ officeId: 1,
+ roles: [restricted.roleId],
+ sendPasswordToEmail: false,
+ password: throwawayPassword,
+ repeatPassword: throwawayPassword,
+ }),
+ ).toBe(403);
+
+ // Targets their *own* role on purpose: lacking UPDATE_ROLE refuses the write regardless
+ // of whose role is named, including an attempt to grant themselves more than they hold.
+ expect(
+ await statusAs(restricted, 'PUT', `/roles/${restricted.roleId}/permissions`, {
+ permissions: { ALL_FUNCTIONS: true },
+ }),
+ ).toBe(403);
+ });
+});
diff --git a/e2e/utils/seed-api.ts b/e2e/utils/seed-api.ts
index a2b82beec..65c3f700e 100644
--- a/e2e/utils/seed-api.ts
+++ b/e2e/utils/seed-api.ts
@@ -857,7 +857,7 @@ const SPECIAL = '#$%&*+-=?@^';
* is no value in having one in the tree when the account is created and used within a single
* test run.
*/
-function generatePassword(): string {
+export function generatePassword(): string {
const pools = [UPPER, LOWER, DIGIT, SPECIAL];
const characters: string[] = [];
// One from each class first, so the policy's lookaheads are satisfied by construction,
@@ -920,7 +920,7 @@ export async function seedRestrictedUser(
*/
export async function statusAs(
user: SeededRestrictedUser,
- method: 'GET' | 'POST',
+ method: 'GET' | 'POST' | 'PUT',
path: string,
body?: unknown,
): Promise {
@@ -935,7 +935,11 @@ export async function statusAs(
try {
const url = `${API_BASE}${path}`;
const response =
- method === 'GET' ? await context.get(url) : await context.post(url, { data: body ?? {} });
+ method === 'GET'
+ ? await context.get(url)
+ : method === 'PUT'
+ ? await context.put(url, { data: body ?? {} })
+ : await context.post(url, { data: body ?? {} });
return response.status();
} finally {
await context.dispose();
diff --git a/e2e/working-capital-loan-actions.spec.ts b/e2e/working-capital-loan-actions.spec.ts
new file mode 100644
index 000000000..08bc178a1
--- /dev/null
+++ b/e2e/working-capital-loan-actions.spec.ts
@@ -0,0 +1,331 @@
+/*
+ * 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.
+ */
+
+/**
+ * Cover for the four Working Capital gaps that had backend endpoints but no UI: delinquency
+ * actions, breach actions, near-breach actions, and per-loan loan-originator attach/detach.
+ * Mocked throughout — these endpoints are Fineract 1.15.0-only and unreachable on the public
+ * community sandbox.
+ */
+
+import { test, expect, Page } from './fixtures';
+import { selectTab } from './utils/ionic-locators';
+import { selectOption } from './utils/select-option';
+
+const TENANT = 'default';
+const USER = 'mifos';
+const PASSWORD = 'password';
+const LOAN_ID = 1;
+
+async function mockLoanView(page: Page) {
+ await page.route(/\/api\/v1\//, async (route) => {
+ await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
+ });
+ await page.route('**/config.json*', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ fineractApiUrl: '/api/v1', defaultTenant: TENANT }),
+ });
+ });
+ await page.route('**/api/v1/authentication**', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ username: USER,
+ userId: 1,
+ base64EncodedAuthenticationKey: 'YmFzZTY0',
+ authenticated: true,
+ officeId: 1,
+ officeName: 'Head Office',
+ roles: [{ id: 1, name: 'Role', description: 'Role' }],
+ permissions: ['ALL_FUNCTIONS'],
+ }),
+ });
+ });
+ await page.route(/\/api\/v1\/businessdate/, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([{ type: 'BUSINESS_DATE', date: [2026, 8, 16] }]),
+ });
+ });
+
+ await page.route(`**/api/v1/working-capital-loans/${LOAN_ID}`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ id: LOAN_ID,
+ accountNo: '000001',
+ client: { id: 7, displayName: 'Acme Ltd' },
+ product: { name: 'WC Revolver' },
+ currency: { displaySymbol: '$' },
+ proposedPrincipal: 10_000,
+ status: { value: 'Active', active: true },
+ }),
+ });
+ });
+ for (const resource of [
+ 'charges',
+ 'transactions',
+ 'delinquency-range-schedule',
+ 'breach-schedule',
+ ]) {
+ await page.route(`**/api/v1/working-capital-loans/${LOAN_ID}/${resource}`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: resource === 'transactions' ? JSON.stringify({ content: [] }) : JSON.stringify([]),
+ });
+ });
+ }
+
+ await page.route(
+ `**/api/v1/working-capital-loans/${LOAN_ID}/delinquency-actions`,
+ async (route) => {
+ if (route.request().method() === 'GET') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ { id: 1, action: 'PAUSE', startDate: '01 January 2026', endDate: '01 February 2026' },
+ ]),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ resourceId: 4 }),
+ });
+ },
+ );
+
+ await page.route(`**/api/v1/working-capital-loans/${LOAN_ID}/breach-actions`, async (route) => {
+ if (route.request().method() === 'GET') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ { id: 1, action: 'PAUSE', startDate: '01 January 2026', endDate: '01 February 2026' },
+ ]),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ resourceId: 2 }),
+ });
+ });
+
+ await page.route(
+ `**/api/v1/working-capital-loans/${LOAN_ID}/near-breach-actions`,
+ async (route) => {
+ if (route.request().method() === 'GET') {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ {
+ id: 1,
+ action: 'RESCHEDULE',
+ frequency: 2,
+ frequencyType: 'WEEKS',
+ threshold: 80,
+ createdDate: '01 January 2026',
+ },
+ ]),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ resourceId: 3 }),
+ });
+ },
+ );
+
+ await page.route(`**/api/v1/working-capital-loans/${LOAN_ID}/originators`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ originators: [{ id: 5, name: 'Acme Originator' }] }),
+ });
+ });
+ await page.route(`**/api/v1/working-capital-loans/${LOAN_ID}/originators/*`, async (route) => {
+ await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
+ });
+ await page.route('**/api/v1/loan-originators', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ { id: 5, name: 'Acme Originator' },
+ { id: 6, name: 'Other Originator' },
+ ]),
+ });
+ });
+}
+
+async function login(page: Page) {
+ await page.goto('/login');
+ await page.locator('#tenantId').fill(TENANT);
+ await page.locator('#username').fill(USER);
+ await page.locator('#password').fill(PASSWORD);
+ await page.getByRole('button', { name: 'Sign In' }).click();
+ await expect(page).toHaveURL('/dashboard');
+}
+
+test.describe('Working Capital loan delinquency, breach and near-breach actions, and originators', () => {
+ test.beforeEach(async ({ page }) => {
+ await mockLoanView(page);
+ await login(page);
+ await page.goto(`/working-capital/loans/view/${LOAN_ID}`);
+ await expect(page.locator('h2')).toContainText('000001');
+ });
+
+ test('shows delinquency action history and submits a new one', async ({ page }) => {
+ await selectTab(page, /^Delinquency Actions$/);
+ await expect(page.getByRole('cell', { name: 'PAUSE' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'New Action' }).click();
+ await expect(page).toHaveURL(`/working-capital/loans/${LOAN_ID}/delinquency-action`);
+
+ await selectOption(page, 'Action', 'RESET');
+ const checkbox = page.locator('ion-checkbox[name="startNewPeriod"]');
+ await checkbox.click();
+
+ const postRequest = page.waitForRequest(
+ (req) =>
+ req.url().includes(`/working-capital-loans/${LOAN_ID}/delinquency-actions`) &&
+ req.method() === 'POST',
+ );
+ await page.getByRole('button', { name: 'Submit' }).click();
+ const request = await postRequest;
+ expect(request.postDataJSON()).toMatchObject({
+ action: 'RESET',
+ startNewPeriod: true,
+ });
+
+ await expect(page).toHaveURL(`/working-capital/loans/view/${LOAN_ID}?tab=delinquencyActions`);
+ });
+
+ test('shows breach action history and submits a new one', async ({ page }) => {
+ // Anchored: an unanchored 'Breach Actions' also substring-matches the "Near-Breach
+ // Actions" tab.
+ await selectTab(page, /^Breach Actions$/);
+ await expect(page.getByRole('cell', { name: 'PAUSE' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'New Action' }).click();
+ await expect(page).toHaveURL(`/working-capital/loans/${LOAN_ID}/breach-action`);
+
+ // RESCHEDULE needs no date picker, unlike PAUSE/RESUME/DISABLE/ENABLE — kept simple to
+ // fill deterministically, consistent with how this suite avoids ion-datetime interaction
+ // where a test's assertion does not actually depend on the date.
+ await selectOption(page, 'Action', 'RESCHEDULE');
+ await page.locator('input[name="frequency"]').fill('2');
+ await selectOption(page, 'Frequency Type', 'WEEKS');
+
+ const postRequest = page.waitForRequest(
+ (req) =>
+ req.url().includes(`/working-capital-loans/${LOAN_ID}/breach-actions`) &&
+ req.method() === 'POST',
+ );
+ await page.getByRole('button', { name: 'Submit' }).click();
+ const request = await postRequest;
+ expect(request.postDataJSON()).toMatchObject({
+ action: 'RESCHEDULE',
+ frequency: 2,
+ frequencyType: 'WEEKS',
+ });
+
+ await expect(page).toHaveURL(`/working-capital/loans/view/${LOAN_ID}?tab=breachActions`);
+ });
+
+ test('shows near-breach action history and submits a new one', async ({ page }) => {
+ await selectTab(page, 'Near-Breach Actions');
+ await expect(page.getByRole('cell', { name: '80%' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'New Action' }).click();
+ await expect(page).toHaveURL(`/working-capital/loans/${LOAN_ID}/near-breach-action`);
+
+ await page.locator('input[name="nearBreachFrequency"]').fill('4');
+ await selectOption(page, 'Frequency Type', 'DAYS');
+ await page.locator('input[name="nearBreachThreshold"]').fill('90');
+
+ const postRequest = page.waitForRequest(
+ (req) =>
+ req.url().includes(`/working-capital-loans/${LOAN_ID}/near-breach-actions`) &&
+ req.method() === 'POST',
+ );
+ await page.getByRole('button', { name: 'Submit' }).click();
+ const request = await postRequest;
+ expect(request.postDataJSON()).toMatchObject({
+ action: 'RESCHEDULE',
+ nearBreachFrequency: 4,
+ nearBreachFrequencyType: 'DAYS',
+ nearBreachThreshold: 90,
+ });
+
+ await expect(page).toHaveURL(`/working-capital/loans/view/${LOAN_ID}?tab=nearBreachActions`);
+ });
+
+ test('attaches and detaches a loan originator', async ({ page }) => {
+ await selectTab(page, 'Originators');
+ await expect(page.getByRole('cell', { name: 'Acme Originator' })).toBeVisible();
+ // The already-attached originator must not be offered again.
+ await expect(page.getByRole('cell', { name: 'Other Originator' })).not.toBeVisible();
+
+ // Not the shared selectOption() helper: it synchronises by asserting no ion-alert/
+ // popover/action-sheet is on screen, but this page's header Actions menu is its own
+ // ion-popover that stays mounted (hidden, not removed) for the page's whole lifetime, so
+ // that assertion can never see a count of 0 here.
+ const originatorSelect = page
+ .locator('ion-item')
+ .filter({ has: page.getByText('Select Originator', { exact: true }) })
+ .locator('ion-select');
+ await originatorSelect.click();
+ const openPopover = page.locator('ion-popover:visible');
+ await openPopover.getByRole('radio', { name: 'Other Originator', exact: true }).click();
+
+ const attachRequest = page.waitForRequest(
+ (req) =>
+ req.url().includes(`/working-capital-loans/${LOAN_ID}/originators/6`) &&
+ req.method() === 'POST',
+ );
+ await page.getByRole('button', { name: 'Attach Originator' }).click();
+ await attachRequest;
+
+ page.on('dialog', (dialog) => dialog.accept());
+ const detachRequest = page.waitForRequest(
+ (req) =>
+ req.url().includes(`/working-capital-loans/${LOAN_ID}/originators/5`) &&
+ req.method() === 'DELETE',
+ );
+ await page
+ .getByRole('row', { name: /Acme Originator/ })
+ .getByRole('button', { name: 'Detach' })
+ .click();
+ await detachRequest;
+ });
+});
diff --git a/package.json b/package.json
index e6f1b67fb..673dc2f9c 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"test:mfe": "ng test fineract-mfe",
"test:e2e": "playwright test",
"test:e2e:local": "FINERACT_SERVER_URL=/fineract-provider/api/v1 FINERACT_TENANT_ID=default FINERACT_USERNAME=mifos FINERACT_PASSWORD=password playwright test --project=mocked --project=backend",
+ "seed:demo-data": "FINERACT_SERVER_URL=/fineract-provider/api/v1 FINERACT_TENANT_ID=default FINERACT_USERNAME=mifos FINERACT_PASSWORD=password playwright test --project=demo-seed",
"e2e:stack": "bash scripts/e2e-stack.sh",
"e2e:stack:fresh": "bash scripts/e2e-stack.sh --fresh",
"e2e:stack:2fa": "bash scripts/e2e-stack-2fa.sh",
diff --git a/playwright.config.ts b/playwright.config.ts
index c1c63d914..bc5fc05e3 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -38,6 +38,7 @@ const BACKEND_SPECS = [
'center-servicing.spec.ts',
'parity-screens.spec.ts',
'rbac-backend-restricted-user.spec.ts',
+ 'rbac-multi-permission.spec.ts',
'client-transfer.spec.ts',
'deposit-account-servicing.spec.ts',
'deposit-product-configuration.spec.ts',
@@ -118,6 +119,11 @@ export default defineConfig({
// than as a CI step means a local run gets the same baseline for free, which is
// what allows those specs to run unconditionally instead of behind an env gate.
{ name: 'setup', testMatch: /backend\.setup\.ts/ },
+ // Populates a demo dataset for manual testing. Deliberately its own project rather than
+ // a member of BACKEND_SPECS or a dependency of it: nothing here is an assertion, so it
+ // must never run as a side effect of `--project=backend` in CI, only when asked for by
+ // name (`--project=demo-seed`, or `npm run seed:demo-data`).
+ { name: 'demo-seed', testMatch: /demo-data\.setup\.ts/ },
{
// Everything that mocks its own backend with page.route(). Needs no Fineract
// and no seeding, so CI can run it without the docker stack and in parallel
diff --git a/public/api/fineract.json b/public/api/fineract.json
index c897bfb57..4c65308ed 100644
--- a/public/api/fineract.json
+++ b/public/api/fineract.json
@@ -11112,13 +11112,21 @@
"schema": { "type": "string" }
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PostLoansLoanIdTransactionsRequest" }
+ }
+ },
+ "required": true
+ },
"responses": {
"default": {
"content": { "application/json": { "schema": { "type": "string" } } },
"description": "default response"
}
},
- "summary": "Disburse Loan by Account Id",
+ "summary": "Loan Repayment by Account Id",
"tags": ["Inter Operation"]
}
},
@@ -30958,16 +30966,22 @@
"description": { "type": "string" },
"documentKey": { "type": "string" },
"documentType": { "$ref": "#/components/schemas/CodeValueData" },
+ "expiryDate": { "type": "string", "format": "date" },
"id": { "type": "integer", "format": "int64" },
+ "issuanceDate": { "type": "string", "format": "date" },
"status": { "type": "string" }
}
},
"ClientIdentifierRequest": {
"type": "object",
"properties": {
+ "dateFormat": { "type": "string", "example": "dd MMMM yyyy" },
"description": { "type": "string", "example": "Document has been verified" },
"documentKey": { "type": "string", "example": "KA-54677" },
"documentTypeId": { "type": "integer", "format": "int64", "example": 1 },
+ "expiryDate": { "type": "string", "example": "01 January 2034" },
+ "issuanceDate": { "type": "string", "example": "01 January 2024" },
+ "locale": { "type": "string", "example": "en" },
"status": { "type": "string", "example": "Active" }
}
},
@@ -33593,7 +33607,9 @@
"description": { "type": "string", "example": "Issued in the year 2--7" },
"documentKey": { "type": "string", "example": "12345" },
"documentType": { "$ref": "#/components/schemas/GetClientsDocumentType" },
- "id": { "type": "integer", "format": "int64", "example": 2 }
+ "expiryDate": { "type": "string", "format": "date" },
+ "id": { "type": "integer", "format": "int64", "example": 2 },
+ "issuanceDate": { "type": "string", "format": "date" }
}
},
"GetClientsClientIdResponse": {
@@ -36164,6 +36180,9 @@
},
"fixedLength": { "type": "integer", "format": "int32", "example": 10 },
"fixedPrincipalPercentagePerInstallment": { "type": "number", "example": 5.5 },
+ "graceOnArrearsAgeing": { "type": "integer", "format": "int32", "example": 1 },
+ "graceOnInterestPayment": { "type": "integer", "format": "int32", "example": 1 },
+ "graceOnPrincipalPayment": { "type": "integer", "format": "int32", "example": 1 },
"id": { "type": "integer", "format": "int64", "example": 11 },
"inArrearsTolerance": { "type": "integer", "format": "int32", "example": 3 },
"includeInBorrowerCycle": { "type": "boolean", "example": true },
@@ -36815,6 +36834,7 @@
"dueDate": { "type": "string", "format": "date" },
"externalId": { "type": "string", "example": "95174ff9-1a75-4d72-a413-6f9b1cb988b7" },
"id": { "type": "integer", "format": "int64", "example": 1 },
+ "loanId": { "type": "integer", "format": "int64", "example": 1 },
"name": { "type": "string", "example": "Loan Processing fee" },
"penalty": { "type": "boolean", "example": false },
"percentage": { "type": "number", "format": "double", "example": 0 },
@@ -36848,6 +36868,16 @@
"position": { "type": "integer", "format": "int32", "example": 0 }
}
},
+ "GetLoansLoanIdCollateralData": {
+ "type": "object",
+ "properties": {
+ "clientCollateralId": { "type": "integer", "format": "int64", "example": 1 },
+ "collateralId": { "type": "integer", "format": "int64", "example": 1 },
+ "quantity": { "type": "number", "example": 1 },
+ "total": { "type": "number", "example": 10000.0 },
+ "totalCollateral": { "type": "number", "example": 10000.0 }
+ }
+ },
"GetLoansLoanIdCollateralsResponse": {
"type": "object",
"description": "GetLoansLoanIdCollateralsResponse",
@@ -37266,6 +37296,10 @@
"type": "object",
"properties": {
"currency": { "$ref": "#/components/schemas/GetLoansLoanIdCurrency" },
+ "futurePeriods": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/GetLoansLoanIdRepaymentPeriod" }
+ },
"loanTermInDays": { "type": "integer", "format": "int64", "example": 30 },
"periods": {
"type": "array",
@@ -37321,6 +37355,10 @@
"clientId": { "type": "integer", "format": "int64", "example": 1 },
"clientName": { "type": "string", "example": "Kampala first Client" },
"clientOfficeId": { "type": "integer", "format": "int64", "example": 2 },
+ "collateral": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/GetLoansLoanIdCollateralData" }
+ },
"currency": { "$ref": "#/components/schemas/GetLoansLoanIdCurrency" },
"delinquencyRange": { "$ref": "#/components/schemas/DelinquencyRangeData" },
"delinquent": { "$ref": "#/components/schemas/GetLoansLoanIdDelinquencySummary" },
@@ -37401,6 +37439,7 @@
"repaymentSchedule": { "$ref": "#/components/schemas/GetLoansLoanIdRepaymentSchedule" },
"repaymentStartDateType": { "$ref": "#/components/schemas/EnumOptionData" },
"status": { "$ref": "#/components/schemas/GetLoansLoanIdStatus" },
+ "subStatus": { "$ref": "#/components/schemas/GetLoansLoanIdSubStatus" },
"summary": { "$ref": "#/components/schemas/GetLoansLoanIdSummary" },
"termFrequency": { "type": "integer", "format": "int32", "example": 12 },
"termPeriodFrequencyType": {
@@ -37435,6 +37474,14 @@
"waitingForDisbursal": { "type": "boolean", "example": false }
}
},
+ "GetLoansLoanIdSubStatus": {
+ "type": "object",
+ "properties": {
+ "code": { "type": "string", "example": "loanSubStatus.foreclosed" },
+ "id": { "type": "integer", "format": "int64", "example": 1 },
+ "value": { "type": "string", "example": "Foreclosed" }
+ }
+ },
"GetLoansLoanIdSummary": {
"type": "object",
"properties": {
@@ -40756,6 +40803,7 @@
"amount": { "type": "number", "example": 10 },
"amountOutstanding": { "type": "number", "example": 0 },
"amountPaid": { "type": "number", "example": 10 },
+ "amountWrittenOff": { "type": "number", "example": 0 },
"chargeCalculationType": { "$ref": "#/components/schemas/EnumOptionData" },
"chargeId": { "type": "integer", "format": "int64", "example": 1 },
"chargePaymentMode": { "$ref": "#/components/schemas/EnumOptionData" },
@@ -41305,7 +41353,14 @@
"status": { "$ref": "#/components/schemas/GetWorkingCapitalLoansLoanIdStatus" },
"summary": { "$ref": "#/components/schemas/GetWorkingCapitalLoanSummary" },
"timeline": { "$ref": "#/components/schemas/GetWorkingCapitalLoansLoanIdTimeline" },
- "totalPaymentVolume": { "type": "number", "example": 10500.0 }
+ "totalPaymentVolume": { "type": "number", "example": 10500.0 },
+ "writeOffReason": { "$ref": "#/components/schemas/CodeValueData" },
+ "writtenOffOnDate": {
+ "type": "string",
+ "format": "date",
+ "description": "Date the loan was written off. Cleared by an undo write-off",
+ "example": "2026-07-16"
+ }
}
},
"GetWorkingCapitalLoansLoanIdStatus": {
@@ -43165,7 +43220,9 @@
"description": "LoanProductChargeToGLAccountMapper",
"properties": {
"charge": { "$ref": "#/components/schemas/LoanProductChargeData" },
- "incomeAccount": { "$ref": "#/components/schemas/GLAccountData" }
+ "chargeId": { "type": "integer", "format": "int64", "example": 1 },
+ "incomeAccount": { "$ref": "#/components/schemas/GLAccountData" },
+ "incomeAccountId": { "type": "integer", "format": "int64", "example": 1 }
}
},
"LoanProductConfigurableAttributes": {
@@ -45101,7 +45158,7 @@
"type": "object",
"description": "PostClientsClientIdChargesRequest",
"properties": {
- "amount": { "type": "integer", "format": "int32", "example": 100 },
+ "amount": { "type": "number", "example": 100 },
"chargeId": { "type": "integer", "format": "int64", "example": 226 },
"dateFormat": { "type": "string", "example": "dd MMMM yyyy" },
"dueDate": { "type": "string", "example": "01 September 2015" },
@@ -45121,9 +45178,13 @@
"type": "object",
"description": "PostClientsClientIdIdentifiersRequest",
"properties": {
+ "dateFormat": { "type": "string", "example": "dd MMMM yyyy" },
"description": { "type": "string", "example": "Document has been verified" },
"documentKey": { "type": "string", "example": "KA-54677" },
"documentTypeId": { "type": "integer", "format": "int64", "example": 1 },
+ "expiryDate": { "type": "string", "example": "01 January 2034" },
+ "issuanceDate": { "type": "string", "example": "01 January 2024" },
+ "locale": { "type": "string", "example": "en" },
"status": { "type": "string", "example": "Active" }
}
},
@@ -46078,6 +46139,11 @@
"items": { "type": "integer", "format": "int32" }
},
"rates": { "type": "array", "items": { "$ref": "#/components/schemas/RateData" } },
+ "recalculationCompoundingFrequencyDayOfWeekType": {
+ "type": "integer",
+ "format": "int32",
+ "example": 1
+ },
"recalculationCompoundingFrequencyInterval": {
"type": "integer",
"format": "int32",
@@ -46093,11 +46159,21 @@
"format": "int32",
"example": 1
},
+ "recalculationRestFrequencyDayOfWeekType": {
+ "type": "integer",
+ "format": "int32",
+ "example": 1
+ },
"recalculationRestFrequencyInterval": {
"type": "integer",
"format": "int32",
"example": 1
},
+ "recalculationRestFrequencyOnDayType": {
+ "type": "integer",
+ "format": "int32",
+ "example": 1
+ },
"recalculationRestFrequencyType": { "type": "integer", "format": "int32", "example": 50 },
"receivableFeeAccountId": { "type": "integer", "format": "int64", "example": 11 },
"receivableInterestAccountId": { "type": "integer", "format": "int64", "example": 9 },
@@ -46346,6 +46422,7 @@
"example": 1000
},
"locale": { "type": "string", "example": "en" },
+ "netDisbursalAmount": { "type": "number", "example": 5000.33 },
"note": { "type": "string", "example": "Description of disbursement details." },
"originators": {
"type": "array",
@@ -46601,6 +46678,10 @@
"items": { "$ref": "#/components/schemas/PostLoansRequestChargeData" }
},
"clientId": { "type": "integer", "format": "int64", "example": 1 },
+ "collateral": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/PostLoansRequestCollateralData" }
+ },
"datatables": {
"type": "array",
"example": "List of PostLoansDataTable",
@@ -46688,7 +46769,15 @@
"type": "object",
"properties": {
"amount": { "type": "number", "example": 1.0 },
- "chargeId": { "type": "integer", "format": "int64", "example": 1 }
+ "chargeId": { "type": "integer", "format": "int64", "example": 1 },
+ "dueDate": { "type": "string", "example": "29 September 2011" }
+ }
+ },
+ "PostLoansRequestCollateralData": {
+ "type": "object",
+ "properties": {
+ "clientCollateralId": { "type": "integer", "format": "int64", "example": 1 },
+ "quantity": { "type": "number", "example": 1 }
}
},
"PostLoansResponse": {
@@ -47091,7 +47180,8 @@
},
"reportSql": { "type": "string", "example": "select 'very good sql' as AComment" },
"reportSubType": { "type": "string" },
- "reportType": { "type": "string", "example": "Table" }
+ "reportType": { "type": "string", "example": "Table" },
+ "useReport": { "type": "boolean", "example": true }
}
},
"PostRolesRequest": {
@@ -47717,7 +47807,7 @@
},
"PostWorkingCapitalLoanTransactionsRequest": {
"type": "object",
- "description": "Request for transaction command: repayment, creditBalanceRefund, discountFee, or discountFeeAdjustment",
+ "description": "Request for transaction command: repayment, creditBalanceRefund, discountFee, discountFeeAdjustment, chargeOff, undoChargeOff, writeOff or undoWriteOff",
"properties": {
"chargeOffReasonId": {
"type": "integer",
@@ -47744,6 +47834,11 @@
"description": "Disbursement transaction id for discountFee; discount fee transaction id for discountFeeAdjustment",
"example": 42
},
+ "reversalExternalId": {
+ "type": "string",
+ "description": "Optional external id for the reversal (command=undoChargeOff, undoWriteOff)",
+ "example": "undo-write-off-ext-001"
+ },
"transactionAmount": {
"type": "number",
"description": "Transaction amount",
@@ -47753,6 +47848,12 @@
"type": "string",
"description": "Transaction date",
"example": "28 June 2024"
+ },
+ "writeoffReasonId": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Optional write-off reason code value id (command=writeOff)",
+ "example": 3
}
}
},
@@ -49751,12 +49852,16 @@
"type": "object",
"description": "PutSavingsAccountsAccountIdRequest",
"properties": {
+ "clientId": { "type": "integer", "format": "int64", "example": 1 },
+ "dateFormat": { "type": "string", "example": "dd MMMM yyyy" },
"locale": { "type": "string", "example": "en" },
"nominalAnnualInterestRate": {
"type": "number",
"format": "double",
"example": 5.9999999999
- }
+ },
+ "productId": { "type": "integer", "format": "int64", "example": 1 },
+ "submittedOnDate": { "type": "string", "example": "01 March 2011" }
}
},
"PutSavingsAccountsAccountIdResponse": {
@@ -49778,7 +49883,8 @@
"type": "number",
"format": "double",
"example": 5.9999999999
- }
+ },
+ "submittedOnDate": { "type": "string", "example": "01 March 2011" }
}
},
"PutSavingsAccountsSavingsAccountIdChargesSavingsAccountChargeIdRequest": {
@@ -52066,6 +52172,7 @@
"nearBreach": { "type": "boolean" },
"numberOfDays": { "type": "integer", "format": "int32" },
"outstandingAmount": { "type": "number" },
+ "paidAmount": { "type": "number" },
"periodNumber": { "type": "integer", "format": "int32" },
"reset": { "type": "boolean" },
"toDate": { "type": "string", "format": "date" }
@@ -52077,6 +52184,7 @@
"amount": { "type": "number" },
"amountOutstanding": { "type": "number" },
"amountPaid": { "type": "number" },
+ "amountWrittenOff": { "type": "number" },
"chargeCalculationType": { "$ref": "#/components/schemas/EnumOptionData" },
"chargeId": { "type": "integer", "format": "int64" },
"chargeOptions": {
diff --git a/public/api/fineract.provenance.json b/public/api/fineract.provenance.json
index adb25a52c..0d71806b8 100644
--- a/public/api/fineract.provenance.json
+++ b/public/api/fineract.provenance.json
@@ -2,27 +2,27 @@
"$comment": "Provenance for public/api/fineract.json. Written by .github/workflows/api-spec-sync.yml; do not edit by hand.",
"license": "Apache-2.0",
"schemaVersion": 1,
- "fetchedAt": "2026-08-17T04:29:59.452Z",
+ "fetchedAt": "2026-08-19T06:08:56.319Z",
"source": {
"project": "apache/fineract",
"method": "image",
"url": "/app/resources/static/fineract.json",
"image": {
"requested": "apache/fineract:latest",
- "ref": "apache/fineract@sha256:e1e2f90c9ab4786f2e560493e592f4ff46ccf0c91697c2c49378c6524779e201",
- "digest": "sha256:e1e2f90c9ab4786f2e560493e592f4ff46ccf0c91697c2c49378c6524779e201"
+ "ref": "apache/fineract@sha256:dd7721faf522eb60d129273f5167d121a4aeb51c2b5a85fe0a92162077dcd15d",
+ "digest": "sha256:dd7721faf522eb60d129273f5167d121a4aeb51c2b5a85fe0a92162077dcd15d"
},
"commit": "unknown"
},
"spec": {
"path": "public/api/fineract.json",
"infoVersion": "1.16.0-SNAPSHOT",
- "upstreamSha256": "88b1126be76571dcd6a9534197ae194f5fa09efbfd9409a3198792c510193dbb",
- "committedSha256": "f25ae4b34b21c48b3fb54c5f26d8f00f3bf23c3bbe81b4457c3a134294c50fa2",
- "committedBytes": 2100108,
+ "upstreamSha256": "6a41d671ea8786b91ad876ff9abf7f89655fe9a9f595e4aea0612638335bae23",
+ "committedSha256": "7042bff962ed439a742fe228958bba1091e046ee5fd25d7d7ad0aaa81557e766",
+ "committedBytes": 2105310,
"pathCount": 594,
"operationCount": 958,
- "schemaCount": 1472
+ "schemaCount": 1475
},
"generator": {
"tool": "openapi-generator-cli",
@@ -32,6 +32,6 @@
"output": "src/app/api"
},
"workflow": {
- "runUrl": "https://github.com/apache/fineract-backoffice-ui/actions/runs/31994536012"
+ "runUrl": "https://github.com/apache/fineract-backoffice-ui/actions/runs/32222063184"
}
}
diff --git a/src/app/api/.openapi-generator/FILES b/src/app/api/.openapi-generator/FILES
index 65e3993b9..bc6a71c30 100644
--- a/src/app/api/.openapi-generator/FILES
+++ b/src/app/api/.openapi-generator/FILES
@@ -670,6 +670,7 @@ model/getLoansLoanIdChargeTimeType.ts
model/getLoansLoanIdChargesChargeIdResponse.ts
model/getLoansLoanIdChargesTemplateResponse.ts
model/getLoansLoanIdCodeValueData.ts
+model/getLoansLoanIdCollateralData.ts
model/getLoansLoanIdCollateralsResponse.ts
model/getLoansLoanIdCurrency.ts
model/getLoansLoanIdDelinquencyPausePeriod.ts
@@ -700,6 +701,7 @@ model/getLoansLoanIdRepaymentPeriod.ts
model/getLoansLoanIdRepaymentSchedule.ts
model/getLoansLoanIdResponse.ts
model/getLoansLoanIdStatus.ts
+model/getLoansLoanIdSubStatus.ts
model/getLoansLoanIdSummary.ts
model/getLoansLoanIdTermPeriodFrequencyType.ts
model/getLoansLoanIdTimeline.ts
@@ -1240,6 +1242,7 @@ model/postLoansOriginatorData.ts
model/postLoansRepaymentSchedulePeriods.ts
model/postLoansRequest.ts
model/postLoansRequestChargeData.ts
+model/postLoansRequestCollateralData.ts
model/postLoansResponse.ts
model/postMakerCheckersResponse.ts
model/postOfficesRequest.ts
diff --git a/src/app/api/api/interOperation.service.ts b/src/app/api/api/interOperation.service.ts
index c64b55028..7c483e699 100644
--- a/src/app/api/api/interOperation.service.ts
+++ b/src/app/api/api/interOperation.service.ts
@@ -56,6 +56,8 @@ import { InteropTransactionsData } from '../model/interopTransactionsData';
import { InteropTransferRequestData } from '../model/interopTransferRequestData';
// @ts-ignore
import { InteropTransferResponseData } from '../model/interopTransferResponseData';
+// @ts-ignore
+import { PostLoansLoanIdTransactionsRequest } from '../model/postLoansLoanIdTransactionsRequest';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS } from '../variables';
@@ -1288,20 +1290,24 @@ export class InterOperationService extends BaseService {
}
/**
- * Disburse Loan by Account Id
+ * Loan Repayment by Account Id
* @endpoint post /v1/interoperation/transactions/{accountId}/loanrepayment
* @param accountId accountId
+ * @param postLoansLoanIdTransactionsRequest
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
* @param options additional options
*/
- public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable;
- public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>;
- public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>;
- public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable {
+ public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, postLoansLoanIdTransactionsRequest: PostLoansLoanIdTransactionsRequest, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable;
+ public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, postLoansLoanIdTransactionsRequest: PostLoansLoanIdTransactionsRequest, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>;
+ public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, postLoansLoanIdTransactionsRequest: PostLoansLoanIdTransactionsRequest, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>;
+ public postInteroperationTransactionsAccountIdLoanrepayment(accountId: string, postLoansLoanIdTransactionsRequest: PostLoansLoanIdTransactionsRequest, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable {
if (accountId === null || accountId === undefined) {
throw new Error('Required parameter accountId was null or undefined when calling postInteroperationTransactionsAccountIdLoanrepayment.');
}
+ if (postLoansLoanIdTransactionsRequest === null || postLoansLoanIdTransactionsRequest === undefined) {
+ throw new Error('Required parameter postLoansLoanIdTransactionsRequest was null or undefined when calling postInteroperationTransactionsAccountIdLoanrepayment.');
+ }
let localVarHeaders = this.defaultHeaders;
@@ -1323,6 +1329,15 @@ export class InterOperationService extends BaseService {
const localVarTransferCache: boolean = options?.transferCache ?? true;
+ // to determine the Content-Type header
+ const consumes: string[] = [
+ 'application/json'
+ ];
+ const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes);
+ if (httpContentTypeSelected !== undefined) {
+ localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected);
+ }
+
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
@@ -1339,6 +1354,7 @@ export class InterOperationService extends BaseService {
return this.httpClient.request('post', `${basePath}${localVarPath}`,
{
context: localVarHttpContext,
+ body: postLoansLoanIdTransactionsRequest,
responseType: responseType_,
...(withCredentials ? { withCredentials } : {}),
headers: localVarHeaders,
diff --git a/src/app/api/model/clientIdentifierData.ts b/src/app/api/model/clientIdentifierData.ts
index 37f82f19f..a4bc5c641 100644
--- a/src/app/api/model/clientIdentifierData.ts
+++ b/src/app/api/model/clientIdentifierData.ts
@@ -32,7 +32,9 @@ export interface ClientIdentifierData {
description?: string;
documentKey?: string;
documentType?: CodeValueData;
+ expiryDate?: string;
id?: number;
+ issuanceDate?: string;
status?: string;
}
diff --git a/src/app/api/model/clientIdentifierRequest.ts b/src/app/api/model/clientIdentifierRequest.ts
index 041047d19..2e6a9f671 100644
--- a/src/app/api/model/clientIdentifierRequest.ts
+++ b/src/app/api/model/clientIdentifierRequest.ts
@@ -26,9 +26,13 @@
export interface ClientIdentifierRequest {
+ dateFormat?: string;
description?: string;
documentKey?: string;
documentTypeId?: number;
+ expiryDate?: string;
+ issuanceDate?: string;
+ locale?: string;
status?: string;
}
diff --git a/src/app/api/model/getClientsClientIdIdentifiersResponse.ts b/src/app/api/model/getClientsClientIdIdentifiersResponse.ts
index c1f1d3f75..f154cae1a 100644
--- a/src/app/api/model/getClientsClientIdIdentifiersResponse.ts
+++ b/src/app/api/model/getClientsClientIdIdentifiersResponse.ts
@@ -34,6 +34,8 @@ export interface GetClientsClientIdIdentifiersResponse {
description?: string;
documentKey?: string;
documentType?: GetClientsDocumentType;
+ expiryDate?: string;
id?: number;
+ issuanceDate?: string;
}
diff --git a/src/app/api/model/getLoanProductsProductIdResponse.ts b/src/app/api/model/getLoanProductsProductIdResponse.ts
index 4b5a1fb1d..2361c4872 100644
--- a/src/app/api/model/getLoanProductsProductIdResponse.ts
+++ b/src/app/api/model/getLoanProductsProductIdResponse.ts
@@ -108,6 +108,9 @@ export interface GetLoanProductsProductIdResponse {
feeToIncomeAccountMappings?: Set;
fixedLength?: number;
fixedPrincipalPercentagePerInstallment?: number;
+ graceOnArrearsAgeing?: number;
+ graceOnInterestPayment?: number;
+ graceOnPrincipalPayment?: number;
id?: number;
inArrearsTolerance?: number;
includeInBorrowerCycle?: boolean;
diff --git a/src/app/api/model/getLoansLoanIdChargesChargeIdResponse.ts b/src/app/api/model/getLoansLoanIdChargesChargeIdResponse.ts
index f8f96d1a4..154ada253 100644
--- a/src/app/api/model/getLoansLoanIdChargesChargeIdResponse.ts
+++ b/src/app/api/model/getLoansLoanIdChargesChargeIdResponse.ts
@@ -46,6 +46,7 @@ export interface GetLoansLoanIdChargesChargeIdResponse {
dueDate?: string;
externalId?: string;
id?: number;
+ loanId?: number;
name?: string;
penalty?: boolean;
percentage?: number;
diff --git a/src/app/api/model/getLoansLoanIdCollateralData.ts b/src/app/api/model/getLoansLoanIdCollateralData.ts
new file mode 100644
index 000000000..a844a7564
--- /dev/null
+++ b/src/app/api/model/getLoansLoanIdCollateralData.ts
@@ -0,0 +1,35 @@
+/**
+ * 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.
+ */
+
+/*
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+
+export interface GetLoansLoanIdCollateralData {
+ clientCollateralId?: number;
+ collateralId?: number;
+ quantity?: number;
+ total?: number;
+ totalCollateral?: number;
+}
+
diff --git a/src/app/api/model/getLoansLoanIdRepaymentSchedule.ts b/src/app/api/model/getLoansLoanIdRepaymentSchedule.ts
index aa10ad2df..0d6440063 100644
--- a/src/app/api/model/getLoansLoanIdRepaymentSchedule.ts
+++ b/src/app/api/model/getLoansLoanIdRepaymentSchedule.ts
@@ -29,6 +29,7 @@ import { GetLoansLoanIdCurrency } from './getLoansLoanIdCurrency';
export interface GetLoansLoanIdRepaymentSchedule {
currency?: GetLoansLoanIdCurrency;
+ futurePeriods?: Array;
loanTermInDays?: number;
periods?: Array;
totalFeeChargesCharged?: number;
diff --git a/src/app/api/model/getLoansLoanIdResponse.ts b/src/app/api/model/getLoansLoanIdResponse.ts
index abd627459..7324fb67c 100644
--- a/src/app/api/model/getLoansLoanIdResponse.ts
+++ b/src/app/api/model/getLoansLoanIdResponse.ts
@@ -23,6 +23,7 @@
* Do not edit the class manually.
*/
+import { GetLoansLoanIdCollateralData } from './getLoansLoanIdCollateralData';
import { GetLoansLoanIdStatus } from './getLoansLoanIdStatus';
import { GetLoansLoanIdRepaymentFrequencyType } from './getLoansLoanIdRepaymentFrequencyType';
import { StringEnumOptionData } from './stringEnumOptionData';
@@ -37,6 +38,7 @@ import { GetLoansLoanIdDelinquencySummary } from './getLoansLoanIdDelinquencySum
import { GetLoansLoanIdOriginatorData } from './getLoansLoanIdOriginatorData';
import { GetLoansLoanIdTransactions } from './getLoansLoanIdTransactions';
import { GetLoansLoanIdLoanChargeData } from './getLoansLoanIdLoanChargeData';
+import { GetLoansLoanIdSubStatus } from './getLoansLoanIdSubStatus';
import { GetLoansLoanIdDisbursementDetails } from './getLoansLoanIdDisbursementDetails';
import { GetLoansLoanIdInterestRateFrequencyType } from './getLoansLoanIdInterestRateFrequencyType';
import { GetLoansLoanIdInterestType } from './getLoansLoanIdInterestType';
@@ -75,6 +77,7 @@ export interface GetLoansLoanIdResponse {
clientId?: number;
clientName?: string;
clientOfficeId?: number;
+ collateral?: Array;
currency?: GetLoansLoanIdCurrency;
delinquencyRange?: DelinquencyRangeData;
delinquent?: GetLoansLoanIdDelinquencySummary;
@@ -138,6 +141,7 @@ export interface GetLoansLoanIdResponse {
repaymentSchedule?: GetLoansLoanIdRepaymentSchedule;
repaymentStartDateType?: EnumOptionData;
status?: GetLoansLoanIdStatus;
+ subStatus?: GetLoansLoanIdSubStatus;
summary?: GetLoansLoanIdSummary;
termFrequency?: number;
termPeriodFrequencyType?: GetLoansLoanIdTermPeriodFrequencyType;
diff --git a/src/app/api/model/getLoansLoanIdSubStatus.ts b/src/app/api/model/getLoansLoanIdSubStatus.ts
new file mode 100644
index 000000000..e81faa654
--- /dev/null
+++ b/src/app/api/model/getLoansLoanIdSubStatus.ts
@@ -0,0 +1,33 @@
+/**
+ * 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.
+ */
+
+/*
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+
+export interface GetLoansLoanIdSubStatus {
+ code?: string;
+ id?: number;
+ value?: string;
+}
+
diff --git a/src/app/api/model/getWorkingCapitalLoanCharge.ts b/src/app/api/model/getWorkingCapitalLoanCharge.ts
index 0dbdac933..3f12ff44c 100644
--- a/src/app/api/model/getWorkingCapitalLoanCharge.ts
+++ b/src/app/api/model/getWorkingCapitalLoanCharge.ts
@@ -34,6 +34,7 @@ export interface GetWorkingCapitalLoanCharge {
amount?: number;
amountOutstanding?: number;
amountPaid?: number;
+ amountWrittenOff?: number;
chargeCalculationType?: EnumOptionData;
chargeId?: number;
chargePaymentMode?: EnumOptionData;
diff --git a/src/app/api/model/getWorkingCapitalLoansLoanIdResponse.ts b/src/app/api/model/getWorkingCapitalLoansLoanIdResponse.ts
index 648611a80..9daa6ce11 100644
--- a/src/app/api/model/getWorkingCapitalLoansLoanIdResponse.ts
+++ b/src/app/api/model/getWorkingCapitalLoansLoanIdResponse.ts
@@ -170,5 +170,10 @@ export interface GetWorkingCapitalLoansLoanIdResponse {
summary?: GetWorkingCapitalLoanSummary;
timeline?: GetWorkingCapitalLoansLoanIdTimeline;
totalPaymentVolume?: number;
+ writeOffReason?: CodeValueData;
+ /**
+ * Date the loan was written off. Cleared by an undo write-off
+ */
+ writtenOffOnDate?: string;
}
diff --git a/src/app/api/model/loanProductChargeToGLAccountMapper.ts b/src/app/api/model/loanProductChargeToGLAccountMapper.ts
index 09bc261a1..c551d6260 100644
--- a/src/app/api/model/loanProductChargeToGLAccountMapper.ts
+++ b/src/app/api/model/loanProductChargeToGLAccountMapper.ts
@@ -32,6 +32,8 @@ import { LoanProductChargeData } from './loanProductChargeData';
*/
export interface LoanProductChargeToGLAccountMapper {
charge?: LoanProductChargeData;
+ chargeId?: number;
incomeAccount?: GLAccountData;
+ incomeAccountId?: number;
}
diff --git a/src/app/api/model/models.ts b/src/app/api/model/models.ts
index c53ed396a..2908a4c4d 100644
--- a/src/app/api/model/models.ts
+++ b/src/app/api/model/models.ts
@@ -512,6 +512,7 @@ export * from './getLoansLoanIdChargeTimeType';
export * from './getLoansLoanIdChargesChargeIdResponse';
export * from './getLoansLoanIdChargesTemplateResponse';
export * from './getLoansLoanIdCodeValueData';
+export * from './getLoansLoanIdCollateralData';
export * from './getLoansLoanIdCollateralsResponse';
export * from './getLoansLoanIdCurrency';
export * from './getLoansLoanIdDelinquencyPausePeriod';
@@ -542,6 +543,7 @@ export * from './getLoansLoanIdRepaymentPeriod';
export * from './getLoansLoanIdRepaymentSchedule';
export * from './getLoansLoanIdResponse';
export * from './getLoansLoanIdStatus';
+export * from './getLoansLoanIdSubStatus';
export * from './getLoansLoanIdSummary';
export * from './getLoansLoanIdTermPeriodFrequencyType';
export * from './getLoansLoanIdTimeline';
@@ -1081,6 +1083,7 @@ export * from './postLoansOriginatorData';
export * from './postLoansRepaymentSchedulePeriods';
export * from './postLoansRequest';
export * from './postLoansRequestChargeData';
+export * from './postLoansRequestCollateralData';
export * from './postLoansResponse';
export * from './postMakerCheckersResponse';
export * from './postOfficesRequest';
diff --git a/src/app/api/model/postClientsClientIdIdentifiersRequest.ts b/src/app/api/model/postClientsClientIdIdentifiersRequest.ts
index 2af76c20a..54e52d84c 100644
--- a/src/app/api/model/postClientsClientIdIdentifiersRequest.ts
+++ b/src/app/api/model/postClientsClientIdIdentifiersRequest.ts
@@ -29,9 +29,13 @@
* PostClientsClientIdIdentifiersRequest
*/
export interface PostClientsClientIdIdentifiersRequest {
+ dateFormat?: string;
description?: string;
documentKey?: string;
documentTypeId?: number;
+ expiryDate?: string;
+ issuanceDate?: string;
+ locale?: string;
status?: string;
}
diff --git a/src/app/api/model/postLoanProductsRequest.ts b/src/app/api/model/postLoanProductsRequest.ts
index cc7ffeb07..b63bc07e0 100644
--- a/src/app/api/model/postLoanProductsRequest.ts
+++ b/src/app/api/model/postLoanProductsRequest.ts
@@ -158,10 +158,13 @@ export interface PostLoanProductsRequest {
principalThresholdForLastInstallment?: number;
principalVariationsForBorrowerCycle?: Array;
rates?: Array;
+ recalculationCompoundingFrequencyDayOfWeekType?: number;
recalculationCompoundingFrequencyInterval?: number;
recalculationCompoundingFrequencyOnDayType?: number;
recalculationCompoundingFrequencyType?: number;
+ recalculationRestFrequencyDayOfWeekType?: number;
recalculationRestFrequencyInterval?: number;
+ recalculationRestFrequencyOnDayType?: number;
recalculationRestFrequencyType?: number;
receivableFeeAccountId?: number;
receivableInterestAccountId?: number;
diff --git a/src/app/api/model/postLoansLoanIdRequest.ts b/src/app/api/model/postLoansLoanIdRequest.ts
index bcfaa4d26..19b00ea27 100644
--- a/src/app/api/model/postLoansLoanIdRequest.ts
+++ b/src/app/api/model/postLoansLoanIdRequest.ts
@@ -55,6 +55,7 @@ export interface PostLoansLoanIdRequest {
*/
glimPrincipal?: number;
locale?: string;
+ netDisbursalAmount?: number;
note?: string;
/**
* Optional array of originators to reconcile during loan disbursement. Omit the field to leave existing mappings unchanged. Send an empty array to detach all originators. Each entry can reference an existing originator by \'id\' or \'externalId\'. Missing externalIds are created during disbursement.
diff --git a/src/app/api/model/postLoansRequest.ts b/src/app/api/model/postLoansRequest.ts
index 74d5fc607..59c5d5637 100644
--- a/src/app/api/model/postLoansRequest.ts
+++ b/src/app/api/model/postLoansRequest.ts
@@ -28,6 +28,7 @@ import { StringEnumOptionData } from './stringEnumOptionData';
import { PostLoansRequestChargeData } from './postLoansRequestChargeData';
import { PostLoansOriginatorData } from './postLoansOriginatorData';
import { PostLoansDisbursementData } from './postLoansDisbursementData';
+import { PostLoansRequestCollateralData } from './postLoansRequestCollateralData';
/**
@@ -47,6 +48,7 @@ export interface PostLoansRequest {
capitalizedIncomeType?: StringEnumOptionData;
charges?: Array;
clientId?: number;
+ collateral?: Array;
datatables?: Array;
dateFormat?: string;
daysInYearCustomStrategy?: PostLoansRequest.DaysInYearCustomStrategyEnum;
diff --git a/src/app/api/model/postLoansRequestChargeData.ts b/src/app/api/model/postLoansRequestChargeData.ts
index 29b6158c4..98b783608 100644
--- a/src/app/api/model/postLoansRequestChargeData.ts
+++ b/src/app/api/model/postLoansRequestChargeData.ts
@@ -28,5 +28,6 @@
export interface PostLoansRequestChargeData {
amount?: number;
chargeId?: number;
+ dueDate?: string;
}
diff --git a/src/app/api/model/postLoansRequestCollateralData.ts b/src/app/api/model/postLoansRequestCollateralData.ts
new file mode 100644
index 000000000..bf9bc046a
--- /dev/null
+++ b/src/app/api/model/postLoansRequestCollateralData.ts
@@ -0,0 +1,32 @@
+/**
+ * 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.
+ */
+
+/*
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+
+export interface PostLoansRequestCollateralData {
+ clientCollateralId?: number;
+ quantity?: number;
+}
+
diff --git a/src/app/api/model/postRepostRequest.ts b/src/app/api/model/postRepostRequest.ts
index 35d2f2bdd..c89851067 100644
--- a/src/app/api/model/postRepostRequest.ts
+++ b/src/app/api/model/postRepostRequest.ts
@@ -36,5 +36,6 @@ export interface PostRepostRequest {
reportSql?: string;
reportSubType?: string;
reportType?: string;
+ useReport?: boolean;
}
diff --git a/src/app/api/model/postWorkingCapitalLoanTransactionsRequest.ts b/src/app/api/model/postWorkingCapitalLoanTransactionsRequest.ts
index 6db3c524c..292014eec 100644
--- a/src/app/api/model/postWorkingCapitalLoanTransactionsRequest.ts
+++ b/src/app/api/model/postWorkingCapitalLoanTransactionsRequest.ts
@@ -27,7 +27,7 @@ import { PostWorkingCapitalLoanTransactionsPaymentDetailRequest } from './postWo
/**
- * Request for transaction command: repayment, creditBalanceRefund, discountFee, or discountFeeAdjustment
+ * Request for transaction command: repayment, creditBalanceRefund, discountFee, discountFeeAdjustment, chargeOff, undoChargeOff, writeOff or undoWriteOff
*/
export interface PostWorkingCapitalLoanTransactionsRequest {
/**
@@ -47,6 +47,10 @@ export interface PostWorkingCapitalLoanTransactionsRequest {
* Disbursement transaction id for discountFee; discount fee transaction id for discountFeeAdjustment
*/
relatedResourceId?: number;
+ /**
+ * Optional external id for the reversal (command=undoChargeOff, undoWriteOff)
+ */
+ reversalExternalId?: string;
/**
* Transaction amount
*/
@@ -55,5 +59,9 @@ export interface PostWorkingCapitalLoanTransactionsRequest {
* Transaction date
*/
transactionDate?: string;
+ /**
+ * Optional write-off reason code value id (command=writeOff)
+ */
+ writeoffReasonId?: number;
}
diff --git a/src/app/api/model/putSavingsAccountsAccountIdRequest.ts b/src/app/api/model/putSavingsAccountsAccountIdRequest.ts
index 2b85d9868..080378e43 100644
--- a/src/app/api/model/putSavingsAccountsAccountIdRequest.ts
+++ b/src/app/api/model/putSavingsAccountsAccountIdRequest.ts
@@ -29,7 +29,11 @@
* PutSavingsAccountsAccountIdRequest
*/
export interface PutSavingsAccountsAccountIdRequest {
+ clientId?: number;
+ dateFormat?: string;
locale?: string;
nominalAnnualInterestRate?: number;
+ productId?: number;
+ submittedOnDate?: string;
}
diff --git a/src/app/api/model/putSavingsAccountsChanges.ts b/src/app/api/model/putSavingsAccountsChanges.ts
index a8b7dde45..b46e384b6 100644
--- a/src/app/api/model/putSavingsAccountsChanges.ts
+++ b/src/app/api/model/putSavingsAccountsChanges.ts
@@ -28,5 +28,6 @@
export interface PutSavingsAccountsChanges {
locale?: string;
nominalAnnualInterestRate?: number;
+ submittedOnDate?: string;
}
diff --git a/src/app/api/model/workingCapitalLoanBreachScheduleData.ts b/src/app/api/model/workingCapitalLoanBreachScheduleData.ts
index 5f0fc2b80..57d82da6f 100644
--- a/src/app/api/model/workingCapitalLoanBreachScheduleData.ts
+++ b/src/app/api/model/workingCapitalLoanBreachScheduleData.ts
@@ -34,6 +34,7 @@ export interface WorkingCapitalLoanBreachScheduleData {
nearBreach?: boolean;
numberOfDays?: number;
outstandingAmount?: number;
+ paidAmount?: number;
periodNumber?: number;
reset?: boolean;
toDate?: string;
diff --git a/src/app/api/model/workingCapitalLoanChargeData.ts b/src/app/api/model/workingCapitalLoanChargeData.ts
index c058d8d23..894fafdbb 100644
--- a/src/app/api/model/workingCapitalLoanChargeData.ts
+++ b/src/app/api/model/workingCapitalLoanChargeData.ts
@@ -33,6 +33,7 @@ export interface WorkingCapitalLoanChargeData {
amount?: number;
amountOutstanding?: number;
amountPaid?: number;
+ amountWrittenOff?: number;
chargeCalculationType?: EnumOptionData;
chargeId?: number;
chargeOptions?: Array;
diff --git a/src/app/core/services/guidance.service.spec.ts b/src/app/core/services/guidance.service.spec.ts
index 53b2dad56..73d5b20b3 100644
--- a/src/app/core/services/guidance.service.spec.ts
+++ b/src/app/core/services/guidance.service.spec.ts
@@ -62,6 +62,15 @@ describe('GuidanceService', () => {
expect(service.currentStep()).toBeNull();
});
+ it('targets the dashboard System Status list specifically, not any on the page', () => {
+ // A bare 'ul' selector matches the sidebar's own `` first, since it
+ // sits earlier in the DOM than the dashboard content — this step ends up highlighting and
+ // scrolling to the sidebar instead of the System Status card it describes.
+ service.startTour('/dashboard');
+ service.nextStep();
+ expect(service.currentStep()?.targetSelector).toBe('.status-list');
+ });
+
it('should match savings routes', () => {
service.startTour('/products/savings-accounts');
expect(service.isPlaying()).toBeTrue();
diff --git a/src/app/core/services/guidance.service.ts b/src/app/core/services/guidance.service.ts
index a02745218..0f4357c21 100644
--- a/src/app/core/services/guidance.service.ts
+++ b/src/app/core/services/guidance.service.ts
@@ -51,7 +51,11 @@ export class GuidanceService {
{
titleKey: 'GUIDE.DASHBOARD_ENV_TITLE',
descriptionKey: 'GUIDE.DASHBOARD_ENV_DESC',
- targetSelector: 'ul',
+ // Must be specific: a bare 'ul' matches the sidebar's own ``
+ // first, since it sits earlier in the DOM than the dashboard content — so this step
+ // ended up highlighting and scrolling to the sidebar's nav list instead of the
+ // System Status card it is actually describing.
+ targetSelector: '.status-list',
},
],
clients: [
diff --git a/src/app/features/interop/interop-transfers.component.ts b/src/app/features/interop/interop-transfers.component.ts
index 5f81e95bc..548d1e6de 100644
--- a/src/app/features/interop/interop-transfers.component.ts
+++ b/src/app/features/interop/interop-transfers.component.ts
@@ -40,7 +40,9 @@ import {
InterOperationService,
InteropTransferRequestData,
InteropTransferResponseData,
+ PostLoansLoanIdTransactionsRequest,
} from '../../api';
+import { FINERACT_DATE_FORMAT, FINERACT_LOCALE } from '../../core/utils/date-formatter';
const ERROR_OCCURRED = 'Error occurred';
@@ -280,8 +282,16 @@ export class InteropTransfersComponent {
loanRepayment(): void {
this.result.set(null);
+ // Fineract added a request body to this endpoint (previously none was accepted); every
+ // field on it is optional, so dateFormat/locale — required by every other Fineract
+ // command on this screen — is the minimal body that is still a real request rather than
+ // an empty placeholder.
+ const body: PostLoansLoanIdTransactionsRequest = {
+ dateFormat: FINERACT_DATE_FORMAT,
+ locale: FINERACT_LOCALE,
+ };
this.interopService
- .postInteroperationTransactionsAccountIdLoanrepayment(this.disburseAccountId)
+ .postInteroperationTransactionsAccountIdLoanrepayment(this.disburseAccountId, body)
.subscribe({
next: (data) => this.result.set(data),
error: (err: { message?: string }) =>
diff --git a/src/app/features/loans/loan-view.component.ts b/src/app/features/loans/loan-view.component.ts
index 098e4d107..78e6af95b 100644
--- a/src/app/features/loans/loan-view.component.ts
+++ b/src/app/features/loans/loan-view.component.ts
@@ -220,6 +220,7 @@ export type LoanTab = (typeof LOAN_TAB)[keyof typeof LOAN_TAB];
@if (isLoanPendingApproval) {
{
+ let component: WcBreachActionFormComponent;
+ let fixture: ComponentFixture;
+ let breachActionsSpy: jasmine.SpyObj;
+ let routerSpy: jasmine.SpyObj;
+
+ beforeEach(() => {
+ breachActionsSpy = jasmine.createSpyObj('WorkingCapitalLoanBreachActionsService', [
+ 'postWorkingCapitalLoansLoanIdBreachActions',
+ ]);
+ routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+ breachActionsSpy.postWorkingCapitalLoansLoanIdBreachActions.and.returnValue(
+ of({}) as ReturnType<
+ WorkingCapitalLoanBreachActionsService['postWorkingCapitalLoansLoanIdBreachActions']
+ >,
+ );
+
+ TestBed.configureTestingModule({
+ imports: [WcBreachActionFormComponent],
+ providers: [
+ { provide: WorkingCapitalLoanBreachActionsService, useValue: breachActionsSpy },
+ { provide: Router, useValue: routerSpy },
+ {
+ provide: ActivatedRoute,
+ useValue: { snapshot: { paramMap: { get: () => '42' } } },
+ },
+ provideNoopAnimations(),
+ ...provideFakeAdapters().providers,
+ ],
+ });
+
+ fixture = TestBed.createComponent(WcBreachActionFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should parse the loan id from the route', () => {
+ expect(component.loanId).toBe(42);
+ });
+
+ it('submits a PAUSE action with start/end dates', () => {
+ component.action = ACTIONS.Pause;
+ component.startDate = '2026-01-01';
+ component.endDate = '2026-02-01';
+ component.onSubmit();
+
+ expect(breachActionsSpy.postWorkingCapitalLoansLoanIdBreachActions).toHaveBeenCalledWith(
+ 42,
+ jasmine.objectContaining({
+ action: ACTIONS.Pause,
+ startDate: jasmine.any(String),
+ endDate: jasmine.any(String),
+ }),
+ );
+ });
+
+ it('submits a RESET action with the restart flag', () => {
+ component.action = ACTIONS.Reset;
+ component.restartFromReset = true;
+ component.onSubmit();
+
+ expect(breachActionsSpy.postWorkingCapitalLoansLoanIdBreachActions).toHaveBeenCalledWith(
+ 42,
+ jasmine.objectContaining({
+ action: ACTIONS.Reset,
+ restartPeriodFromResetDate: true,
+ }),
+ );
+ });
+
+ it('submits a RESCHEDULE action with frequency fields', () => {
+ component.action = ACTIONS.Reschedule;
+ component.request.frequency = 3;
+ component.request.frequencyType = component.frequencyTypeOptions[0];
+ component.onSubmit();
+
+ expect(breachActionsSpy.postWorkingCapitalLoansLoanIdBreachActions).toHaveBeenCalledWith(
+ 42,
+ jasmine.objectContaining({
+ action: ACTIONS.Reschedule,
+ frequency: 3,
+ }),
+ );
+ });
+
+ it('navigates back to the loan view on success, on the breach-actions tab', () => {
+ component.action = ACTIONS.Resume;
+ component.onSubmit();
+
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans/view/42'], {
+ queryParams: { tab: 'breachActions' },
+ });
+ });
+
+ it('stops saving and does not navigate away when the request fails', () => {
+ breachActionsSpy.postWorkingCapitalLoansLoanIdBreachActions.and.returnValue(
+ throwError(() => new Error('boom')),
+ );
+ component.action = ACTIONS.Enable;
+ component.onSubmit();
+
+ expect(component.isSaving()).toBeFalse();
+ expect(routerSpy.navigate).not.toHaveBeenCalled();
+ });
+
+ it('cancel navigates back without submitting', () => {
+ component.onCancel();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans/view/42'], {
+ queryParams: { tab: 'breachActions' },
+ });
+ expect(breachActionsSpy.postWorkingCapitalLoansLoanIdBreachActions).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/features/working-capital/loans/wc-breach-action-form.component.ts b/src/app/features/working-capital/loans/wc-breach-action-form.component.ts
new file mode 100644
index 000000000..cbf5d3a4c
--- /dev/null
+++ b/src/app/features/working-capital/loans/wc-breach-action-form.component.ts
@@ -0,0 +1,314 @@
+/*
+ * 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 { Component, OnInit, inject, signal } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { TranslatePipe } from '../../../core/adapters';
+import {
+ IonButton,
+ IonCard,
+ IonCardContent,
+ IonCardHeader,
+ IonCardTitle,
+ IonCheckbox,
+ IonDatetime,
+ IonDatetimeButton,
+ IonInput,
+ IonItem,
+ IonLabel,
+ IonModal,
+ IonSelect,
+ IonSelectOption,
+ IonSpinner,
+} from '@ionic/angular/standalone';
+import {
+ WorkingCapitalLoanBreachActionsService,
+ PostWorkingCapitalLoansBreachActionRequest,
+ WorkingCapitalLoanBreachActionData,
+} from '../../../api';
+import {
+ FINERACT_DATE_FORMAT,
+ FINERACT_LOCALE,
+ formatDateToFineract,
+} from '../../../core/utils/date-formatter';
+
+const ACTIONS = WorkingCapitalLoanBreachActionData.ActionEnum;
+const FREQUENCY_TYPES = WorkingCapitalLoanBreachActionData.FrequencyTypeEnum;
+const MINIMUM_PAYMENT_TYPES = WorkingCapitalLoanBreachActionData.MinimumPaymentTypeEnum;
+
+/**
+ * Submits a covenant-breach action (pause, resume, reschedule, reset, undo_reset, disable,
+ * enable) for a single Working Capital loan. Field visibility follows the action-specific
+ * constraints documented on `PostWorkingCapitalLoansBreachActionRequest` — mirrors the
+ * per-command `@if` branching in `WcLoanActionFormComponent`.
+ */
+@Component({
+ selector: 'app-wc-breach-action-form',
+ standalone: true,
+ imports: [
+ FormsModule,
+ TranslatePipe,
+ IonButton,
+ IonSpinner,
+ IonInput,
+ IonItem,
+ IonLabel,
+ IonCardContent,
+ IonCardHeader,
+ IonCardTitle,
+ IonCard,
+ IonSelectOption,
+ IonSelect,
+ IonCheckbox,
+ IonDatetime,
+ IonDatetimeButton,
+ IonModal,
+ ],
+ template: `
+
+ `,
+ styles: [
+ `
+ .form-container {
+ padding: 24px;
+ max-width: 600px;
+ margin: 0 auto;
+ }
+ .wc-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+ `,
+ ],
+})
+export class WcBreachActionFormComponent implements OnInit {
+ private readonly breachActionsService = inject(WorkingCapitalLoanBreachActionsService);
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+
+ protected readonly ACTIONS = ACTIONS;
+ readonly actionOptions = Object.values(ACTIONS);
+ readonly frequencyTypeOptions = Object.values(FREQUENCY_TYPES);
+ readonly minimumPaymentTypeOptions = Object.values(MINIMUM_PAYMENT_TYPES);
+
+ loanId = 0;
+ readonly isSaving = signal(false);
+
+ action: WorkingCapitalLoanBreachActionData.ActionEnum | undefined;
+ startDate: string | null = null;
+ endDate: string | null = null;
+ restartFromReset = false;
+
+ request: PostWorkingCapitalLoansBreachActionRequest = {
+ dateFormat: FINERACT_DATE_FORMAT,
+ locale: FINERACT_LOCALE,
+ };
+
+ ngOnInit(): void {
+ const id = this.route.snapshot.paramMap.get('id');
+ if (id) this.loanId = +id;
+ }
+
+ onSubmit(): void {
+ this.isSaving.set(true);
+
+ const request: PostWorkingCapitalLoansBreachActionRequest = {
+ ...this.request,
+ action: this.action,
+ };
+ if (this.startDate) {
+ request.startDate = formatDateToFineract(this.startDate);
+ }
+ if (this.endDate) {
+ request.endDate = formatDateToFineract(this.endDate);
+ }
+ if (this.action === ACTIONS.Reset) {
+ request.restartPeriodFromResetDate = this.restartFromReset;
+ }
+
+ this.breachActionsService
+ .postWorkingCapitalLoansLoanIdBreachActions(this.loanId, request)
+ .subscribe({
+ next: () => this.onCancel(),
+ error: () => this.isSaving.set(false),
+ });
+ }
+
+ onCancel(): void {
+ this.router.navigate([`/working-capital/loans/view/${this.loanId}`], {
+ queryParams: { tab: 'breachActions' },
+ });
+ }
+}
diff --git a/src/app/features/working-capital/loans/wc-delinquency-action-form.component.spec.ts b/src/app/features/working-capital/loans/wc-delinquency-action-form.component.spec.ts
new file mode 100644
index 000000000..79ca1a771
--- /dev/null
+++ b/src/app/features/working-capital/loans/wc-delinquency-action-form.component.spec.ts
@@ -0,0 +1,159 @@
+/*
+ * 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 { WcDelinquencyActionFormComponent } from './wc-delinquency-action-form.component';
+import {
+ WorkingCapitalLoanDelinquencyActionsService,
+ WorkingCapitalLoanDelinquencyActionData,
+} from '../../../api';
+import { ActivatedRoute, Router } from '@angular/router';
+import { of, throwError } from 'rxjs';
+import { provideNoopAnimations } from '@angular/platform-browser/animations';
+import { provideFakeAdapters } from '../../../testing/adapters';
+
+const ACTIONS = WorkingCapitalLoanDelinquencyActionData.ActionEnum;
+
+describe('WcDelinquencyActionFormComponent', () => {
+ let component: WcDelinquencyActionFormComponent;
+ let fixture: ComponentFixture;
+ let delinquencyActionsSpy: jasmine.SpyObj;
+ let routerSpy: jasmine.SpyObj;
+
+ beforeEach(() => {
+ delinquencyActionsSpy = jasmine.createSpyObj('WorkingCapitalLoanDelinquencyActionsService', [
+ 'postWorkingCapitalLoansLoanIdDelinquencyActions',
+ ]);
+ routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+ delinquencyActionsSpy.postWorkingCapitalLoansLoanIdDelinquencyActions.and.returnValue(
+ of({}) as ReturnType<
+ WorkingCapitalLoanDelinquencyActionsService['postWorkingCapitalLoansLoanIdDelinquencyActions']
+ >,
+ );
+
+ TestBed.configureTestingModule({
+ imports: [WcDelinquencyActionFormComponent],
+ providers: [
+ {
+ provide: WorkingCapitalLoanDelinquencyActionsService,
+ useValue: delinquencyActionsSpy,
+ },
+ { provide: Router, useValue: routerSpy },
+ {
+ provide: ActivatedRoute,
+ useValue: { snapshot: { paramMap: { get: () => '42' } } },
+ },
+ provideNoopAnimations(),
+ ...provideFakeAdapters().providers,
+ ],
+ });
+
+ fixture = TestBed.createComponent(WcDelinquencyActionFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should parse the loan id from the route', () => {
+ expect(component.loanId).toBe(42);
+ });
+
+ it('submits a PAUSE action with start/end dates', () => {
+ component.action = ACTIONS.Pause;
+ component.startDate = '2026-01-01';
+ component.endDate = '2026-02-01';
+ component.onSubmit();
+
+ expect(
+ delinquencyActionsSpy.postWorkingCapitalLoansLoanIdDelinquencyActions,
+ ).toHaveBeenCalledWith(
+ 42,
+ jasmine.objectContaining({
+ action: ACTIONS.Pause,
+ startDate: jasmine.any(String),
+ endDate: jasmine.any(String),
+ }),
+ );
+ });
+
+ it('submits a RESET action with the start-new-period flag', () => {
+ component.action = ACTIONS.Reset;
+ component.startNewPeriod = true;
+ component.onSubmit();
+
+ expect(
+ delinquencyActionsSpy.postWorkingCapitalLoansLoanIdDelinquencyActions,
+ ).toHaveBeenCalledWith(
+ 42,
+ jasmine.objectContaining({
+ action: ACTIONS.Reset,
+ startNewPeriod: true,
+ }),
+ );
+ });
+
+ it('submits a RESCHEDULE action with frequency and minimum payment fields', () => {
+ component.action = ACTIONS.Reschedule;
+ component.request.frequency = 3;
+ component.request.frequencyType = component.frequencyTypeOptions[0];
+ component.request.minimumPayment = 50;
+ component.request.minimumPaymentType = component.minimumPaymentTypeOptions[0];
+ component.onSubmit();
+
+ expect(
+ delinquencyActionsSpy.postWorkingCapitalLoansLoanIdDelinquencyActions,
+ ).toHaveBeenCalledWith(
+ 42,
+ jasmine.objectContaining({
+ action: ACTIONS.Reschedule,
+ frequency: 3,
+ minimumPayment: 50,
+ }),
+ );
+ });
+
+ it('navigates back to the loan view on success, on the delinquency-actions tab', () => {
+ component.action = ACTIONS.Resume;
+ component.onSubmit();
+
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans/view/42'], {
+ queryParams: { tab: 'delinquencyActions' },
+ });
+ });
+
+ it('stops saving and does not navigate away when the request fails', () => {
+ delinquencyActionsSpy.postWorkingCapitalLoansLoanIdDelinquencyActions.and.returnValue(
+ throwError(() => new Error('boom')),
+ );
+ component.action = ACTIONS.Enable;
+ component.onSubmit();
+
+ expect(component.isSaving()).toBeFalse();
+ expect(routerSpy.navigate).not.toHaveBeenCalled();
+ });
+
+ it('cancel navigates back without submitting', () => {
+ component.onCancel();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans/view/42'], {
+ queryParams: { tab: 'delinquencyActions' },
+ });
+ expect(
+ delinquencyActionsSpy.postWorkingCapitalLoansLoanIdDelinquencyActions,
+ ).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/features/working-capital/loans/wc-delinquency-action-form.component.ts b/src/app/features/working-capital/loans/wc-delinquency-action-form.component.ts
new file mode 100644
index 000000000..e40842196
--- /dev/null
+++ b/src/app/features/working-capital/loans/wc-delinquency-action-form.component.ts
@@ -0,0 +1,316 @@
+/*
+ * 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 { Component, OnInit, inject, signal } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { TranslatePipe } from '../../../core/adapters';
+import {
+ IonButton,
+ IonCard,
+ IonCardContent,
+ IonCardHeader,
+ IonCardTitle,
+ IonCheckbox,
+ IonDatetime,
+ IonDatetimeButton,
+ IonInput,
+ IonItem,
+ IonLabel,
+ IonModal,
+ IonSelect,
+ IonSelectOption,
+ IonSpinner,
+} from '@ionic/angular/standalone';
+import {
+ WorkingCapitalLoanDelinquencyActionsService,
+ PostWorkingCapitalLoansDelinquencyActionRequest,
+ WorkingCapitalLoanDelinquencyActionData,
+} from '../../../api';
+import {
+ FINERACT_DATE_FORMAT,
+ FINERACT_LOCALE,
+ formatDateToFineract,
+} from '../../../core/utils/date-formatter';
+
+const ACTIONS = WorkingCapitalLoanDelinquencyActionData.ActionEnum;
+const FREQUENCY_TYPES = WorkingCapitalLoanDelinquencyActionData.FrequencyTypeEnum;
+const MINIMUM_PAYMENT_TYPES = WorkingCapitalLoanDelinquencyActionData.MinimumPaymentTypeEnum;
+
+/**
+ * Submits a delinquency action (pause, resume, reschedule, reset, undo_reset, disable, enable)
+ * for a single Working Capital loan. Mirrors `WcBreachActionFormComponent` — same action set and
+ * field shape — except RESET here toggles `startNewPeriod` rather than
+ * `restartPeriodFromResetDate`.
+ */
+@Component({
+ selector: 'app-wc-delinquency-action-form',
+ standalone: true,
+ imports: [
+ FormsModule,
+ TranslatePipe,
+ IonButton,
+ IonSpinner,
+ IonInput,
+ IonItem,
+ IonLabel,
+ IonCardContent,
+ IonCardHeader,
+ IonCardTitle,
+ IonCard,
+ IonSelectOption,
+ IonSelect,
+ IonCheckbox,
+ IonDatetime,
+ IonDatetimeButton,
+ IonModal,
+ ],
+ template: `
+
+ `,
+ styles: [
+ `
+ .form-container {
+ padding: 24px;
+ max-width: 600px;
+ margin: 0 auto;
+ }
+ .wc-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+ `,
+ ],
+})
+export class WcDelinquencyActionFormComponent implements OnInit {
+ private readonly delinquencyActionsService = inject(WorkingCapitalLoanDelinquencyActionsService);
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+
+ protected readonly ACTIONS = ACTIONS;
+ readonly actionOptions = Object.values(ACTIONS);
+ readonly frequencyTypeOptions = Object.values(FREQUENCY_TYPES);
+ readonly minimumPaymentTypeOptions = Object.values(MINIMUM_PAYMENT_TYPES);
+
+ loanId = 0;
+ readonly isSaving = signal(false);
+
+ action: WorkingCapitalLoanDelinquencyActionData.ActionEnum | undefined;
+ startDate: string | null = null;
+ endDate: string | null = null;
+ startNewPeriod = false;
+
+ request: PostWorkingCapitalLoansDelinquencyActionRequest = {
+ dateFormat: FINERACT_DATE_FORMAT,
+ locale: FINERACT_LOCALE,
+ };
+
+ ngOnInit(): void {
+ const id = this.route.snapshot.paramMap.get('id');
+ if (id) this.loanId = +id;
+ }
+
+ onSubmit(): void {
+ this.isSaving.set(true);
+
+ const request: PostWorkingCapitalLoansDelinquencyActionRequest = {
+ ...this.request,
+ action: this.action,
+ };
+ if (this.startDate) {
+ request.startDate = formatDateToFineract(this.startDate);
+ }
+ if (this.endDate) {
+ request.endDate = formatDateToFineract(this.endDate);
+ }
+ if (this.action === ACTIONS.Reset) {
+ request.startNewPeriod = this.startNewPeriod;
+ }
+
+ this.delinquencyActionsService
+ .postWorkingCapitalLoansLoanIdDelinquencyActions(this.loanId, request)
+ .subscribe({
+ next: () => this.onCancel(),
+ error: () => this.isSaving.set(false),
+ });
+ }
+
+ onCancel(): void {
+ this.router.navigate([`/working-capital/loans/view/${this.loanId}`], {
+ queryParams: { tab: 'delinquencyActions' },
+ });
+ }
+}
diff --git a/src/app/features/working-capital/loans/wc-loan-view.component.spec.ts b/src/app/features/working-capital/loans/wc-loan-view.component.spec.ts
index ebc20cae7..8e20e3766 100644
--- a/src/app/features/working-capital/loans/wc-loan-view.component.spec.ts
+++ b/src/app/features/working-capital/loans/wc-loan-view.component.spec.ts
@@ -26,6 +26,10 @@ import {
WorkingCapitalLoanDelinquencyActionsService,
WorkingCapitalLoanDelinquencyRangeScheduleService,
WorkingCapitalLoanBreachScheduleService,
+ WorkingCapitalLoanBreachActionsService,
+ WorkingCapitalLoanNearBreachActionsService,
+ WorkingCapitalLoanOriginatorsService,
+ LoanOriginatorsService,
} from '../../../api';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
@@ -41,6 +45,10 @@ describe('WcLoanViewComponent', () => {
let delinquencyActionsSpy: jasmine.SpyObj;
let delinquencyRangeSpy: jasmine.SpyObj;
let breachScheduleSpy: jasmine.SpyObj;
+ let breachActionsSpy: jasmine.SpyObj;
+ let nearBreachActionsSpy: jasmine.SpyObj;
+ let wcOriginatorsSpy: jasmine.SpyObj;
+ let originatorsSpy: jasmine.SpyObj;
let routerSpy: jasmine.SpyObj;
beforeEach(async () => {
@@ -61,6 +69,18 @@ describe('WcLoanViewComponent', () => {
breachScheduleSpy = jasmine.createSpyObj('WorkingCapitalLoanBreachScheduleService', [
'getWorkingCapitalLoansLoanIdBreachSchedule',
]);
+ breachActionsSpy = jasmine.createSpyObj('WorkingCapitalLoanBreachActionsService', [
+ 'getWorkingCapitalLoansLoanIdBreachActions',
+ ]);
+ nearBreachActionsSpy = jasmine.createSpyObj('WorkingCapitalLoanNearBreachActionsService', [
+ 'getWorkingCapitalLoansLoanIdNearBreachActions',
+ ]);
+ wcOriginatorsSpy = jasmine.createSpyObj('WorkingCapitalLoanOriginatorsService', [
+ 'getWorkingCapitalLoansLoanIdOriginators',
+ 'postWorkingCapitalLoansLoanIdOriginatorsOriginatorId',
+ 'deleteWorkingCapitalLoansLoanIdOriginatorsOriginatorId',
+ ]);
+ originatorsSpy = jasmine.createSpyObj('LoanOriginatorsService', ['getLoanOriginators']);
routerSpy = jasmine.createSpyObj('Router', ['navigate']);
loansSpy.getWorkingCapitalLoansLoanId.and.returnValue(
@@ -96,6 +116,37 @@ describe('WcLoanViewComponent', () => {
WorkingCapitalLoanBreachScheduleService['getWorkingCapitalLoansLoanIdBreachSchedule']
>,
);
+ breachActionsSpy.getWorkingCapitalLoansLoanIdBreachActions.and.returnValue(
+ of([{ id: 1, action: 'PAUSE' }]) as unknown as ReturnType<
+ WorkingCapitalLoanBreachActionsService['getWorkingCapitalLoansLoanIdBreachActions']
+ >,
+ );
+ nearBreachActionsSpy.getWorkingCapitalLoansLoanIdNearBreachActions.and.returnValue(
+ of([{ id: 1, action: 'RESCHEDULE', threshold: 80 }]) as unknown as ReturnType<
+ WorkingCapitalLoanNearBreachActionsService['getWorkingCapitalLoansLoanIdNearBreachActions']
+ >,
+ );
+ wcOriginatorsSpy.getWorkingCapitalLoansLoanIdOriginators.and.returnValue(
+ of({ originators: [{ id: 5, name: 'Acme Originator' }] }) as unknown as ReturnType<
+ WorkingCapitalLoanOriginatorsService['getWorkingCapitalLoansLoanIdOriginators']
+ >,
+ );
+ wcOriginatorsSpy.postWorkingCapitalLoansLoanIdOriginatorsOriginatorId.and.returnValue(
+ of({}) as unknown as ReturnType<
+ WorkingCapitalLoanOriginatorsService['postWorkingCapitalLoansLoanIdOriginatorsOriginatorId']
+ >,
+ );
+ wcOriginatorsSpy.deleteWorkingCapitalLoansLoanIdOriginatorsOriginatorId.and.returnValue(
+ of({}) as unknown as ReturnType<
+ WorkingCapitalLoanOriginatorsService['deleteWorkingCapitalLoansLoanIdOriginatorsOriginatorId']
+ >,
+ );
+ originatorsSpy.getLoanOriginators.and.returnValue(
+ of([
+ { id: 5, name: 'Acme Originator' },
+ { id: 6, name: 'Other Originator' },
+ ]) as unknown as ReturnType,
+ );
await TestBed.configureTestingModule({
imports: [WcLoanViewComponent, TranslateModule.forRoot()],
@@ -109,10 +160,19 @@ describe('WcLoanViewComponent', () => {
useValue: delinquencyRangeSpy,
},
{ provide: WorkingCapitalLoanBreachScheduleService, useValue: breachScheduleSpy },
+ { provide: WorkingCapitalLoanBreachActionsService, useValue: breachActionsSpy },
+ { provide: WorkingCapitalLoanNearBreachActionsService, useValue: nearBreachActionsSpy },
+ { provide: WorkingCapitalLoanOriginatorsService, useValue: wcOriginatorsSpy },
+ { provide: LoanOriginatorsService, useValue: originatorsSpy },
{ provide: Router, useValue: routerSpy },
{
provide: ActivatedRoute,
- useValue: { snapshot: { paramMap: convertToParamMap({ id: '1' }) } },
+ useValue: {
+ snapshot: {
+ paramMap: convertToParamMap({ id: '1' }),
+ queryParamMap: convertToParamMap({}),
+ },
+ },
},
provideNoopAnimations(),
],
@@ -133,10 +193,115 @@ describe('WcLoanViewComponent', () => {
expect(component.delinquencyActions()).toHaveSize(1);
expect(component.delinquencyRangeSchedule()).toHaveSize(1);
expect(component.breachSchedule()).toHaveSize(1);
+ expect(component.breachActions()).toHaveSize(1);
+ expect(component.nearBreachActions()).toHaveSize(1);
+ expect(component.originators()).toEqual([{ id: 5, name: 'Acme Originator' }]);
});
it('should navigate back to the list', () => {
component.onBack();
expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans']);
});
+
+ it('should navigate to the delinquency-action form', () => {
+ component.onNewDelinquencyAction();
+ expect(routerSpy.navigate).toHaveBeenCalledWith([
+ '/working-capital/loans/1/delinquency-action',
+ ]);
+ });
+
+ it('should navigate to the breach-action form', () => {
+ component.onNewBreachAction();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans/1/breach-action']);
+ });
+
+ it('should navigate to the near-breach-action form', () => {
+ component.onNewNearBreachAction();
+ expect(routerSpy.navigate).toHaveBeenCalledWith([
+ '/working-capital/loans/1/near-breach-action',
+ ]);
+ });
+
+ it('preselects the tab named in the ?tab query param', async () => {
+ TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ imports: [WcLoanViewComponent, TranslateModule.forRoot()],
+ providers: [
+ { provide: WorkingCapitalLoansService, useValue: loansSpy },
+ { provide: WorkingCapitalLoanChargesService, useValue: chargesSpy },
+ { provide: WorkingCapitalLoanTransactionsService, useValue: transactionsSpy },
+ { provide: WorkingCapitalLoanDelinquencyActionsService, useValue: delinquencyActionsSpy },
+ {
+ provide: WorkingCapitalLoanDelinquencyRangeScheduleService,
+ useValue: delinquencyRangeSpy,
+ },
+ { provide: WorkingCapitalLoanBreachScheduleService, useValue: breachScheduleSpy },
+ { provide: WorkingCapitalLoanBreachActionsService, useValue: breachActionsSpy },
+ { provide: WorkingCapitalLoanNearBreachActionsService, useValue: nearBreachActionsSpy },
+ { provide: WorkingCapitalLoanOriginatorsService, useValue: wcOriginatorsSpy },
+ { provide: LoanOriginatorsService, useValue: originatorsSpy },
+ { provide: Router, useValue: routerSpy },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ paramMap: convertToParamMap({ id: '1' }),
+ queryParamMap: convertToParamMap({ tab: 'originators' }),
+ },
+ },
+ },
+ provideNoopAnimations(),
+ ],
+ }).compileComponents();
+
+ const taggedFixture = TestBed.createComponent(WcLoanViewComponent);
+ taggedFixture.detectChanges();
+ expect(taggedFixture.componentInstance.activeTab()).toBe('originators');
+ });
+
+ it('attaches the selected originator and reloads the attached list', () => {
+ component.originatorToAttach.set(6);
+ component.onAttachOriginator();
+
+ expect(
+ wcOriginatorsSpy.postWorkingCapitalLoansLoanIdOriginatorsOriginatorId,
+ ).toHaveBeenCalledWith(1, 6);
+ expect(component.originatorToAttach()).toBeNull();
+ expect(wcOriginatorsSpy.getWorkingCapitalLoansLoanIdOriginators).toHaveBeenCalledTimes(2);
+ });
+
+ it('does nothing when attaching without a selection', () => {
+ component.originatorToAttach.set(null);
+ component.onAttachOriginator();
+
+ expect(
+ wcOriginatorsSpy.postWorkingCapitalLoansLoanIdOriginatorsOriginatorId,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('detaches an originator after confirmation', () => {
+ spyOn(window, 'confirm').and.returnValue(true);
+
+ component.onDetachOriginator({ id: 5, name: 'Acme Originator' });
+
+ expect(
+ wcOriginatorsSpy.deleteWorkingCapitalLoansLoanIdOriginatorsOriginatorId,
+ ).toHaveBeenCalledWith(1, 5);
+ });
+
+ it('does not detach an originator when the confirmation is declined', () => {
+ spyOn(window, 'confirm').and.returnValue(false);
+
+ component.onDetachOriginator({ id: 5, name: 'Acme Originator' });
+
+ expect(
+ wcOriginatorsSpy.deleteWorkingCapitalLoansLoanIdOriginatorsOriginatorId,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('excludes already-attached originators from the attachable list', () => {
+ // The master list has originators 5 and 6; 5 is already attached (per the default mock),
+ // so only 6 should be offered.
+ expect(component.attachableOriginators()).toEqual([{ id: 6, name: 'Other Originator' }]);
+ });
});
diff --git a/src/app/features/working-capital/loans/wc-loan-view.component.ts b/src/app/features/working-capital/loans/wc-loan-view.component.ts
index becebd1a9..9b74748a3 100644
--- a/src/app/features/working-capital/loans/wc-loan-view.component.ts
+++ b/src/app/features/working-capital/loans/wc-loan-view.component.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Component, OnInit, inject, signal } from '@angular/core';
+import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DecimalPipe } from '@angular/common';
@@ -32,7 +32,10 @@ import {
IonPopover,
IonSegment,
IonSegmentButton,
+ IonSelect,
+ IonSelectOption,
} from '@ionic/angular/standalone';
+import { FormsModule } from '@angular/forms';
import { CdkTableModule } from '@angular/cdk/table';
import { TooltipDirective } from '../../../shared/directives/tooltip.directive';
import {
@@ -42,18 +45,26 @@ import {
WorkingCapitalLoanDelinquencyActionsService,
WorkingCapitalLoanDelinquencyRangeScheduleService,
WorkingCapitalLoanBreachScheduleService,
+ WorkingCapitalLoanBreachActionsService,
+ WorkingCapitalLoanNearBreachActionsService,
+ WorkingCapitalLoanOriginatorsService,
+ LoanOriginatorsService,
GetWorkingCapitalLoansLoanIdResponse,
WorkingCapitalLoanChargeData,
GetWorkingCapitalLoanTransactionIdResponse,
WorkingCapitalLoanDelinquencyActionData,
WorkingCapitalLoanDelinquencyRangeScheduleData,
WorkingCapitalLoanBreachScheduleData,
+ WorkingCapitalLoanBreachActionData,
+ WorkingCapitalLoanNearBreachActionData,
+ LoanOriginatorData,
} from '../../../api';
/**
- * Detail view for a single Working Capital Loan. Shows a Details key/value
- * summary plus read-only tabs for charges, transactions, delinquency actions,
- * delinquency range schedule and breach schedule, each backed by its own GET.
+ * Detail view for a single Working Capital Loan. Shows a Details key/value summary plus tabs for
+ * charges, transactions, delinquency range schedule and breach schedule (read-only, each backed
+ * by its own GET), and delinquency actions, breach actions, near-breach actions and originators
+ * (read/write).
*/
/**
* The tabs on this screen, named.
@@ -69,6 +80,9 @@ export const WC_LOAN_TAB = {
delinquencyActions: 'delinquencyActions',
delinquencyRangeSchedule: 'delinquencyRangeSchedule',
breachSchedule: 'breachSchedule',
+ breachActions: 'breachActions',
+ nearBreachActions: 'nearBreachActions',
+ originators: 'originators',
} as const;
export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
@@ -78,6 +92,7 @@ export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
standalone: true,
imports: [
TranslateModule,
+ FormsModule,
CdkTableModule,
DecimalPipe,
IonIcon,
@@ -86,6 +101,8 @@ export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
IonCard,
IonSegment,
IonSegmentButton,
+ IonSelect,
+ IonSelectOption,
IonLabel,
IonPopover,
IonList,
@@ -207,6 +224,15 @@ export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
{{ 'WC_LOANS.TABS.BREACH_SCHEDULE' | translate }}
+
+ {{ 'WC_LOANS.TABS.BREACH_ACTIONS' | translate }}
+
+
+ {{ 'WC_LOANS.TABS.NEAR_BREACH_ACTIONS' | translate }}
+
+
+ {{ 'WC_LOANS.TABS.ORIGINATORS' | translate }}
+
@if (activeTab() === TAB.details) {
@@ -329,6 +355,12 @@ export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
}
@if (activeTab() === TAB.delinquencyActions) {
+
+
+
+ {{ 'WC_LOANS.DELINQUENCY_ACTION.NEW' | translate }}
+
+
@if (delinquencyActions().length > 0) {
@@ -349,6 +381,14 @@ export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
{{ a.endDate }}
+
+
+ {{ 'WC_LOANS.DELINQUENCY_ACTION.FREQUENCY' | translate }}
+
+
+ {{ a.frequency ? a.frequency + ' ' + a.frequencyType : '-' }}
+
+
@@ -446,6 +486,184 @@ export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
}
+ @if (activeTab() === TAB.breachActions) {
+
+
+
+
+ {{ 'WC_LOANS.BREACH_ACTION.NEW' | translate }}
+
+
+
+
+ @if (breachActions().length > 0) {
+
+
+ {{ 'WC_LOANS.ACTION' | translate }}
+ {{ a.action }}
+
+
+
+ {{ 'WC_LOANS.START_DATE' | translate }}
+
+ {{ a.startDate }}
+
+
+ {{ 'WC_LOANS.END_DATE' | translate }}
+ {{ a.endDate }}
+
+
+
+ {{ 'WC_LOANS.BREACH_ACTION.FREQUENCY' | translate }}
+
+
+ {{ a.frequency ? a.frequency + ' ' + a.frequencyType : '-' }}
+
+
+
+
+
+ } @else {
+
+
+
{{ 'WC_LOANS.NO_DATA' | translate }}
+
+ }
+
+
+
+ }
+ @if (activeTab() === TAB.nearBreachActions) {
+
+
+
+
+ {{ 'WC_LOANS.NEAR_BREACH_ACTION.NEW' | translate }}
+
+
+
+
+ @if (nearBreachActions().length > 0) {
+
+
+ {{ 'WC_LOANS.ACTION' | translate }}
+ {{ a.action }}
+
+
+
+ {{ 'WC_LOANS.NEAR_BREACH_ACTION.FREQUENCY' | translate }}
+
+ {{ a.frequency }} {{ a.frequencyType }}
+
+
+
+ {{ 'WC_LOANS.NEAR_BREACH_ACTION.THRESHOLD' | translate }}
+
+ {{ a.threshold }}%
+
+
+
+ {{ 'WC_LOANS.NEAR_BREACH_ACTION.CREATED_DATE' | translate }}
+
+ {{ a.createdDate }}
+
+
+
+
+ } @else {
+
+
+
{{ 'WC_LOANS.NO_DATA' | translate }}
+
+ }
+
+
+
+ }
+ @if (activeTab() === TAB.originators) {
+
+
+
+ {{
+ 'WC_LOANS.ORIGINATORS.SELECT_ORIGINATOR' | translate
+ }}
+
+ @for (opt of attachableOriginators(); track opt.id) {
+ {{ opt.name }}
+ }
+
+
+
+
+ {{ 'WC_LOANS.ORIGINATORS.ATTACH' | translate }}
+
+
+
+
+ @if (originators().length > 0) {
+
+
+
+ {{ 'WC_LOANS.ORIGINATORS.NAME' | translate }}
+
+ {{ o.name }}
+
+
+
+ {{ 'WC_LOANS.ORIGINATORS.TYPE' | translate }}
+
+ {{ o.originatorType?.name }}
+
+
+
+ {{ 'WC_LOANS.ORIGINATORS.CHANNEL' | translate }}
+
+ {{ o.channelType?.name }}
+
+
+
+ {{ 'WC_LOANS.ORIGINATORS.STATUS' | translate }}
+
+ {{ o.status }}
+
+
+
+ {{ 'COMMON.ACTIONS' | translate }}
+
+
+
+
+
+
+
+
+
+
+ } @else {
+
+
+
{{ 'WC_LOANS.NO_DATA' | translate }}
+
+ }
+
+
+
+ }
`,
styles: [
@@ -541,6 +759,17 @@ export type WcLoanTab = (typeof WC_LOAN_TAB)[keyof typeof WC_LOAN_TAB];
border: 1px solid var(--border-color);
box-shadow: none;
}
+ .tab-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+ .tab-toolbar .attach-select {
+ min-width: 260px;
+ --min-height: 0;
+ }
.full-width-table {
width: 100%;
}
@@ -578,6 +807,10 @@ export class WcLoanViewComponent implements OnInit {
WorkingCapitalLoanDelinquencyRangeScheduleService,
);
private readonly breachScheduleService = inject(WorkingCapitalLoanBreachScheduleService);
+ private readonly breachActionsService = inject(WorkingCapitalLoanBreachActionsService);
+ private readonly nearBreachActionsService = inject(WorkingCapitalLoanNearBreachActionsService);
+ private readonly wcOriginatorsService = inject(WorkingCapitalLoanOriginatorsService);
+ private readonly originatorsService = inject(LoanOriginatorsService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@@ -588,12 +821,25 @@ export class WcLoanViewComponent implements OnInit {
readonly delinquencyActions = signal([]);
readonly delinquencyRangeSchedule = signal([]);
readonly breachSchedule = signal([]);
+ readonly breachActions = signal([]);
+ readonly nearBreachActions = signal([]);
+ readonly originators = signal([]);
+ /** The full master list, used to offer only originators not already attached to this loan. */
+ private readonly allOriginators = signal([]);
+ readonly attachableOriginators = computed(() => {
+ const attachedIds = new Set(this.originators().map((o) => o.id));
+ return this.allOriginators().filter((o) => !attachedIds.has(o.id));
+ });
+ readonly originatorToAttach = signal(null);
chargeColumns = ['name', 'amount', 'paid', 'outstanding'];
transactionColumns = ['id', 'date', 'type', 'amount'];
- delinquencyActionColumns = ['action', 'startDate', 'endDate'];
+ delinquencyActionColumns = ['action', 'startDate', 'endDate', 'frequency'];
delinquencyRangeColumns = ['periodNumber', 'fromDate', 'toDate', 'outstanding'];
breachScheduleColumns = ['periodNumber', 'fromDate', 'toDate', 'breach'];
+ breachActionColumns = ['action', 'startDate', 'endDate', 'frequency'];
+ nearBreachActionColumns = ['action', 'frequency', 'threshold', 'createdDate'];
+ originatorColumns = ['name', 'type', 'channel', 'status', 'actions'];
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
@@ -601,6 +847,11 @@ export class WcLoanViewComponent implements OnInit {
this.loanId = +id;
this.loadData();
}
+
+ const tab = this.route.snapshot.queryParamMap.get('tab');
+ if (tab && Object.values(WC_LOAN_TAB).includes(tab as WcLoanTab)) {
+ this.activeTab.set(tab as WcLoanTab);
+ }
}
loadData(): void {
@@ -637,6 +888,32 @@ export class WcLoanViewComponent implements OnInit {
next: (data) => this.breachSchedule.set(data ?? []),
error: (err: unknown) => console.error('Failed to load breach schedule', err),
});
+
+ this.breachActionsService.getWorkingCapitalLoansLoanIdBreachActions(this.loanId).subscribe({
+ next: (data) => this.breachActions.set(data ?? []),
+ error: (err: unknown) => console.error('Failed to load breach actions', err),
+ });
+
+ this.nearBreachActionsService
+ .getWorkingCapitalLoansLoanIdNearBreachActions(this.loanId)
+ .subscribe({
+ next: (data) => this.nearBreachActions.set(data ?? []),
+ error: (err: unknown) => console.error('Failed to load near-breach actions', err),
+ });
+
+ this.loadOriginators();
+
+ this.originatorsService.getLoanOriginators().subscribe({
+ next: (data) => this.allOriginators.set(data ?? []),
+ error: (err: unknown) => console.error('Failed to load loan originators', err),
+ });
+ }
+
+ loadOriginators(): void {
+ this.wcOriginatorsService.getWorkingCapitalLoansLoanIdOriginators(this.loanId).subscribe({
+ next: (data) => this.originators.set(data.originators ?? []),
+ error: (err: unknown) => console.error('Failed to load loan originators for this loan', err),
+ });
}
get isLoanPendingApproval(): boolean {
@@ -663,6 +940,42 @@ export class WcLoanViewComponent implements OnInit {
this.router.navigate([`/working-capital/loans/edit/${this.loanId}`]);
}
+ onNewDelinquencyAction(): void {
+ this.router.navigate([`/working-capital/loans/${this.loanId}/delinquency-action`]);
+ }
+
+ onNewBreachAction(): void {
+ this.router.navigate([`/working-capital/loans/${this.loanId}/breach-action`]);
+ }
+
+ onNewNearBreachAction(): void {
+ this.router.navigate([`/working-capital/loans/${this.loanId}/near-breach-action`]);
+ }
+
+ onAttachOriginator(): void {
+ const originatorId = this.originatorToAttach();
+ if (!originatorId) return;
+ this.wcOriginatorsService
+ .postWorkingCapitalLoansLoanIdOriginatorsOriginatorId(this.loanId, originatorId)
+ .subscribe({
+ next: () => {
+ this.originatorToAttach.set(null);
+ this.loadOriginators();
+ },
+ error: (err: unknown) => console.error('Failed to attach originator', err),
+ });
+ }
+
+ onDetachOriginator(originator: LoanOriginatorData): void {
+ if (!originator.id || !confirm(`Detach ${originator.name} from this loan?`)) return;
+ this.wcOriginatorsService
+ .deleteWorkingCapitalLoansLoanIdOriginatorsOriginatorId(this.loanId, originator.id)
+ .subscribe({
+ next: () => this.loadOriginators(),
+ error: (err: unknown) => console.error('Failed to detach originator', err),
+ });
+ }
+
onDelete(): void {
if (!confirm('Delete this loan?')) return;
this.loansService.deleteWorkingCapitalLoansLoanId(this.loanId).subscribe({
diff --git a/src/app/features/working-capital/loans/wc-near-breach-action-form.component.spec.ts b/src/app/features/working-capital/loans/wc-near-breach-action-form.component.spec.ts
new file mode 100644
index 000000000..4a5d3b623
--- /dev/null
+++ b/src/app/features/working-capital/loans/wc-near-breach-action-form.component.spec.ts
@@ -0,0 +1,116 @@
+/*
+ * 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 { WcNearBreachActionFormComponent } from './wc-near-breach-action-form.component';
+import {
+ WorkingCapitalLoanNearBreachActionsService,
+ PostWorkingCapitalLoansLoanIdNearBreachActionsRequest,
+} from '../../../api';
+import { ActivatedRoute, Router } from '@angular/router';
+import { of, throwError } from 'rxjs';
+import { provideNoopAnimations } from '@angular/platform-browser/animations';
+import { provideFakeAdapters } from '../../../testing/adapters';
+
+describe('WcNearBreachActionFormComponent', () => {
+ let component: WcNearBreachActionFormComponent;
+ let fixture: ComponentFixture;
+ let nearBreachActionsSpy: jasmine.SpyObj;
+ let routerSpy: jasmine.SpyObj;
+
+ beforeEach(() => {
+ nearBreachActionsSpy = jasmine.createSpyObj('WorkingCapitalLoanNearBreachActionsService', [
+ 'postWorkingCapitalLoansLoanIdNearBreachActions',
+ ]);
+ routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+ nearBreachActionsSpy.postWorkingCapitalLoansLoanIdNearBreachActions.and.returnValue(
+ of({}) as ReturnType<
+ WorkingCapitalLoanNearBreachActionsService['postWorkingCapitalLoansLoanIdNearBreachActions']
+ >,
+ );
+
+ TestBed.configureTestingModule({
+ imports: [WcNearBreachActionFormComponent],
+ providers: [
+ { provide: WorkingCapitalLoanNearBreachActionsService, useValue: nearBreachActionsSpy },
+ { provide: Router, useValue: routerSpy },
+ {
+ provide: ActivatedRoute,
+ useValue: { snapshot: { paramMap: { get: () => '42' } } },
+ },
+ provideNoopAnimations(),
+ ...provideFakeAdapters().providers,
+ ],
+ });
+
+ fixture = TestBed.createComponent(WcNearBreachActionFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should parse the loan id from the route', () => {
+ expect(component.loanId).toBe(42);
+ });
+
+ it('defaults the action to RESCHEDULE, the only value the API accepts', () => {
+ expect(component.request.action).toBe(
+ PostWorkingCapitalLoansLoanIdNearBreachActionsRequest.ActionEnum.Reschedule,
+ );
+ });
+
+ it('submits the near-breach action with the entered values', () => {
+ component.request.nearBreachFrequency = 5;
+ component.request.nearBreachFrequencyType =
+ PostWorkingCapitalLoansLoanIdNearBreachActionsRequest.NearBreachFrequencyTypeEnum.Weeks;
+ component.request.nearBreachThreshold = 90;
+ component.onSubmit();
+
+ expect(
+ nearBreachActionsSpy.postWorkingCapitalLoansLoanIdNearBreachActions,
+ ).toHaveBeenCalledWith(42, component.request);
+ });
+
+ it('navigates back to the loan view on success, on the near-breach-actions tab', () => {
+ component.onSubmit();
+
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans/view/42'], {
+ queryParams: { tab: 'nearBreachActions' },
+ });
+ });
+
+ it('stops saving and does not navigate away when the request fails', () => {
+ nearBreachActionsSpy.postWorkingCapitalLoansLoanIdNearBreachActions.and.returnValue(
+ throwError(() => new Error('boom')),
+ );
+ component.onSubmit();
+
+ expect(component.isSaving()).toBeFalse();
+ expect(routerSpy.navigate).not.toHaveBeenCalled();
+ });
+
+ it('cancel navigates back without submitting', () => {
+ component.onCancel();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/working-capital/loans/view/42'], {
+ queryParams: { tab: 'nearBreachActions' },
+ });
+ expect(
+ nearBreachActionsSpy.postWorkingCapitalLoansLoanIdNearBreachActions,
+ ).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/features/working-capital/loans/wc-near-breach-action-form.component.ts b/src/app/features/working-capital/loans/wc-near-breach-action-form.component.ts
new file mode 100644
index 000000000..a19f88c94
--- /dev/null
+++ b/src/app/features/working-capital/loans/wc-near-breach-action-form.component.ts
@@ -0,0 +1,197 @@
+/*
+ * 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 { Component, OnInit, inject, signal } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { TranslatePipe } from '../../../core/adapters';
+import {
+ IonButton,
+ IonCard,
+ IonCardContent,
+ IonCardHeader,
+ IonCardTitle,
+ IonInput,
+ IonItem,
+ IonLabel,
+ IonSelect,
+ IonSelectOption,
+ IonSpinner,
+} from '@ionic/angular/standalone';
+import {
+ WorkingCapitalLoanNearBreachActionsService,
+ PostWorkingCapitalLoansLoanIdNearBreachActionsRequest,
+} from '../../../api';
+
+const FREQUENCY_TYPES =
+ PostWorkingCapitalLoansLoanIdNearBreachActionsRequest.NearBreachFrequencyTypeEnum;
+
+/**
+ * Submits a near-breach reschedule action for a single Working Capital loan.
+ * `action` is fixed to RESCHEDULE — the only value the API accepts — so the form does not
+ * expose a one-option select for it.
+ */
+@Component({
+ selector: 'app-wc-near-breach-action-form',
+ standalone: true,
+ imports: [
+ FormsModule,
+ TranslatePipe,
+ IonButton,
+ IonSpinner,
+ IonInput,
+ IonItem,
+ IonLabel,
+ IonCardContent,
+ IonCardHeader,
+ IonCardTitle,
+ IonCard,
+ IonSelectOption,
+ IonSelect,
+ ],
+ template: `
+
+ `,
+ styles: [
+ `
+ .form-container {
+ padding: 24px;
+ max-width: 600px;
+ margin: 0 auto;
+ }
+ .wc-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+ `,
+ ],
+})
+export class WcNearBreachActionFormComponent implements OnInit {
+ private readonly nearBreachActionsService = inject(WorkingCapitalLoanNearBreachActionsService);
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+
+ readonly frequencyTypeOptions = Object.values(FREQUENCY_TYPES);
+
+ loanId = 0;
+ readonly isSaving = signal(false);
+
+ request: PostWorkingCapitalLoansLoanIdNearBreachActionsRequest = {
+ action: PostWorkingCapitalLoansLoanIdNearBreachActionsRequest.ActionEnum.Reschedule,
+ nearBreachFrequency: 0,
+ nearBreachFrequencyType: FREQUENCY_TYPES.Days,
+ nearBreachThreshold: 0,
+ };
+
+ ngOnInit(): void {
+ const id = this.route.snapshot.paramMap.get('id');
+ if (id) this.loanId = +id;
+ }
+
+ onSubmit(): void {
+ this.isSaving.set(true);
+ this.nearBreachActionsService
+ .postWorkingCapitalLoansLoanIdNearBreachActions(this.loanId, this.request)
+ .subscribe({
+ next: () => this.onCancel(),
+ error: () => this.isSaving.set(false),
+ });
+ }
+
+ onCancel(): void {
+ this.router.navigate([`/working-capital/loans/view/${this.loanId}`], {
+ queryParams: { tab: 'nearBreachActions' },
+ });
+ }
+}
diff --git a/src/app/features/working-capital/working-capital.routes.ts b/src/app/features/working-capital/working-capital.routes.ts
index 749ecf08f..193b3eed3 100644
--- a/src/app/features/working-capital/working-capital.routes.ts
+++ b/src/app/features/working-capital/working-capital.routes.ts
@@ -126,6 +126,31 @@ export const WORKING_CAPITAL_ROUTES: Routes = [
loadComponent: () =>
import('./loans/wc-loan-action-form.component').then((m) => m.WcLoanActionFormComponent),
},
+ {
+ path: 'loans/:id/breach-action',
+ canActivate: [authGuard, permissionGuard],
+ data: { permissions: 'UPDATE_WORKINGCAPITALLOAN' },
+ loadComponent: () =>
+ import('./loans/wc-breach-action-form.component').then((m) => m.WcBreachActionFormComponent),
+ },
+ {
+ path: 'loans/:id/near-breach-action',
+ canActivate: [authGuard, permissionGuard],
+ data: { permissions: 'UPDATE_WORKINGCAPITALLOAN' },
+ loadComponent: () =>
+ import('./loans/wc-near-breach-action-form.component').then(
+ (m) => m.WcNearBreachActionFormComponent,
+ ),
+ },
+ {
+ path: 'loans/:id/delinquency-action',
+ canActivate: [authGuard, permissionGuard],
+ data: { permissions: 'UPDATE_WORKINGCAPITALLOAN' },
+ loadComponent: () =>
+ import('./loans/wc-delinquency-action-form.component').then(
+ (m) => m.WcDelinquencyActionFormComponent,
+ ),
+ },
{
path: 'loans/account-locks',
canActivate: [authGuard, permissionGuard],
diff --git a/src/app/layout/header.component.spec.ts b/src/app/layout/header.component.spec.ts
index 8c5bd8dcd..b400eb967 100644
--- a/src/app/layout/header.component.spec.ts
+++ b/src/app/layout/header.component.spec.ts
@@ -18,6 +18,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
import { HeaderComponent } from './header.component';
import { AuthService } from '../core/services/auth.service';
import { NavigationConfigService } from '../core/services/navigation-config.service';
@@ -40,7 +41,7 @@ describe('HeaderComponent', () => {
});
navigationConfigSpy = jasmine.createSpyObj('NavigationConfigService', ['searchRoutes']);
navigationConfigSpy.searchRoutes.and.returnValue([]);
- routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+ routerSpy = jasmine.createSpyObj('Router', ['navigate', 'navigateByUrl']);
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), HeaderComponent],
@@ -78,4 +79,37 @@ describe('HeaderComponent', () => {
component.switchLanguage('hi');
expect(translateService.use).toHaveBeenCalledWith('hi');
});
+
+ it('navigates to a page result via navigateByUrl', () => {
+ component.onResultSelected({
+ kind: 'nav',
+ nav: { route: '/organization/offices', label: 'Offices' },
+ });
+ expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/organization/offices');
+ });
+
+ it('navigates to an entity result by type', () => {
+ component.onResultSelected({
+ kind: 'entity',
+ entity: { entityType: 'LOAN', entityId: 42 } as never,
+ });
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/loans/view', 42]);
+ });
+
+ it('prevents the mousedown default on a result item, so the searchbar never blurs to start the 150ms hide race', () => {
+ // Drives `showResults` through the public entry point rather than reaching into the
+ // (protected) signal directly; `searchResults` is set directly since the debounced
+ // pipeline behind `onSearchInput` never resolves within a synchronous test.
+ component.onSearchInput({ detail: { value: 'Offices' } } as unknown as Event);
+ component.searchResults.set([
+ { kind: 'nav', nav: { route: '/organization/offices', label: 'Offices' } },
+ ]);
+ fixture.detectChanges();
+
+ const item = fixture.debugElement.query(By.css('ion-item'));
+ const event = jasmine.createSpyObj('MouseEvent', ['preventDefault']);
+ item.triggerEventHandler('mousedown', event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
});
diff --git a/src/app/layout/header.component.ts b/src/app/layout/header.component.ts
index b1edfd71a..f1f649003 100644
--- a/src/app/layout/header.component.ts
+++ b/src/app/layout/header.component.ts
@@ -96,6 +96,7 @@ type HeaderSearchResult =
button
role="option"
[attr.data-testid]="resultTestId(result)"
+ (mousedown)="$event.preventDefault()"
(click)="onResultSelected(result)"
>
@@ -474,6 +475,13 @@ export class HeaderComponent implements OnInit {
/**
* Hides the results after a beat — hiding immediately on blur would unmount the list
* before the click that caused the blur lands on a result.
+ *
+ * The beat is a fallback for blurs from elsewhere (e.g. Escape, clicking outside), not the
+ * mechanism a result click relies on: `(mousedown)="$event.preventDefault()"` on each result
+ * item stops the searchbar from blurring at all when a result is the click's target, so this
+ * timeout never has to race the click under load. It used to be that race — on a slow
+ * render, the 150ms could elapse before the click event landed, collapsing the list out from
+ * under the click and silently swallowing the navigation.
*/
onSearchBlur() {
setTimeout(() => this.showResults.set(false), 150);
diff --git a/src/app/shared/components/guidance-tour/guidance-tour.component.spec.ts b/src/app/shared/components/guidance-tour/guidance-tour.component.spec.ts
index 6b4b4c64c..1d28bb76a 100644
--- a/src/app/shared/components/guidance-tour/guidance-tour.component.spec.ts
+++ b/src/app/shared/components/guidance-tour/guidance-tour.component.spec.ts
@@ -71,4 +71,48 @@ describe('GuidanceTourComponent', () => {
exitBtn.nativeElement.click();
expect(guidanceServiceSpy.endTour).toHaveBeenCalled();
});
+
+ it('renders Exit/Back through translation keys rather than hardcoded English', () => {
+ // No loader is configured, so ngx-translate falls back to echoing the key itself — this
+ // fails against a hardcoded 'Exit'/'Back' string and passes once the template goes
+ // through `| translate`.
+ const buttons = fixture.debugElement.queryAll(By.css('ion-button'));
+ const text = (i: number) => buttons[i].nativeElement.textContent.trim();
+ expect(text(0)).toBe('COMMON.EXIT');
+ expect(text(1)).toBe('COMMON.BACK');
+ });
+
+ it('renders the Finish label translation key on the last (here, only) step', () => {
+ // The default fixture's single-step array makes index 0 both first and last.
+ const buttons = fixture.debugElement.queryAll(By.css('ion-button'));
+ expect(buttons[2].nativeElement.textContent.trim()).toBe('COMMON.FINISH');
+ });
+
+ it('renders the Next label translation key on a step that is not the last', async () => {
+ guidanceServiceSpy = jasmine.createSpyObj(
+ 'GuidanceService',
+ ['nextStep', 'previousStep', 'endTour'],
+ {
+ isPlaying: signal(true),
+ currentStepIndex: signal(0),
+ activeSteps: signal([
+ { titleKey: 'Title', descriptionKey: 'Desc' },
+ { titleKey: 'Title2', descriptionKey: 'Desc2' },
+ ]),
+ currentStep: signal({ titleKey: 'Title', descriptionKey: 'Desc' }),
+ },
+ );
+
+ await TestBed.resetTestingModule()
+ .configureTestingModule({
+ imports: [GuidanceTourComponent, TranslateModule.forRoot()],
+ providers: [{ provide: GuidanceService, useValue: guidanceServiceSpy }],
+ })
+ .compileComponents();
+
+ const multiStepFixture = TestBed.createComponent(GuidanceTourComponent);
+ multiStepFixture.detectChanges();
+ const buttons = multiStepFixture.debugElement.queryAll(By.css('ion-button'));
+ expect(buttons[2].nativeElement.textContent.trim()).toBe('COMMON.NEXT');
+ });
});
diff --git a/src/app/shared/components/guidance-tour/guidance-tour.component.ts b/src/app/shared/components/guidance-tour/guidance-tour.component.ts
index 5834e2ae6..0419f2808 100644
--- a/src/app/shared/components/guidance-tour/guidance-tour.component.ts
+++ b/src/app/shared/components/guidance-tour/guidance-tour.component.ts
@@ -64,20 +64,23 @@ import { GuidanceService } from '../../../core/services/guidance.service';
- Exit
+
+ {{ 'COMMON.EXIT' | translate }}
+
- Back
+ {{ 'COMMON.BACK' | translate }}
{{
- guidanceService.currentStepIndex() === guidanceService.activeSteps().length - 1
- ? 'Finish'
- : 'Next'
+ (guidanceService.currentStepIndex() === guidanceService.activeSteps().length - 1
+ ? 'COMMON.FINISH'
+ : 'COMMON.NEXT'
+ ) | translate
}}
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 46dc2909f..befbe9b71 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -199,8 +199,10 @@
"LOAD_FAILED": "This list could not be loaded."
},
"EVERY": "every",
+ "EXIT": "Exit",
"EXTERNAL_ID": "External ID",
"FILTERS": "Filters",
+ "FINISH": "Finish",
"FIRST_PAGE": "First page",
"FREQUENCY": "Frequency",
"FROM_DATE": "From Date",
@@ -221,6 +223,7 @@
"MOVE_DOWN": "Move down",
"MOVE_UP": "Move up",
"NAME": "Name",
+ "NEXT": "Next",
"NEXT_PAGE": "Next page",
"NO": "No",
"NOTE": "Note",
@@ -1580,7 +1583,10 @@
"TRANSACTIONS": "Transactions",
"DELINQUENCY_ACTIONS": "Delinquency Actions",
"DELINQUENCY_RANGE_SCHEDULE": "Delinquency Range Schedule",
- "BREACH_SCHEDULE": "Breach Schedule"
+ "BREACH_SCHEDULE": "Breach Schedule",
+ "BREACH_ACTIONS": "Breach Actions",
+ "NEAR_BREACH_ACTIONS": "Near-Breach Actions",
+ "ORIGINATORS": "Originators"
},
"DELINQUENCY_BUCKET": "Delinquency Bucket",
"FUND": "Fund",
@@ -1606,6 +1612,47 @@
"REJECTED_ON_DATE": "Rejected On Date",
"TRANSACTION_DATE": "Transaction Date",
"NOTE": "Note"
+ },
+ "DELINQUENCY_ACTION": {
+ "TITLE": "New Delinquency Action",
+ "NEW": "New Action",
+ "ACTION": "Action",
+ "START_DATE": "Start Date",
+ "END_DATE": "End Date",
+ "FREQUENCY": "Frequency",
+ "FREQUENCY_TYPE": "Frequency Type",
+ "MINIMUM_PAYMENT": "Minimum Payment",
+ "MINIMUM_PAYMENT_TYPE": "Minimum Payment Type",
+ "START_NEW_PERIOD": "Start new period"
+ },
+ "BREACH_ACTION": {
+ "TITLE": "New Breach Action",
+ "NEW": "New Action",
+ "ACTION": "Action",
+ "START_DATE": "Start Date",
+ "END_DATE": "End Date",
+ "FREQUENCY": "Frequency",
+ "FREQUENCY_TYPE": "Frequency Type",
+ "MINIMUM_PAYMENT": "Minimum Payment",
+ "MINIMUM_PAYMENT_TYPE": "Minimum Payment Type",
+ "RESTART_PERIOD": "Restart period from reset date"
+ },
+ "NEAR_BREACH_ACTION": {
+ "TITLE": "New Near-Breach Action",
+ "NEW": "New Action",
+ "FREQUENCY": "Frequency",
+ "FREQUENCY_TYPE": "Frequency Type",
+ "THRESHOLD": "Threshold %",
+ "CREATED_DATE": "Created Date"
+ },
+ "ORIGINATORS": {
+ "ATTACH": "Attach Originator",
+ "DETACH": "Detach",
+ "SELECT_ORIGINATOR": "Select Originator",
+ "NAME": "Name",
+ "TYPE": "Type",
+ "CHANNEL": "Channel",
+ "STATUS": "Status"
}
},
"LOANS_POINT_IN_TIME": {
diff --git a/src/assets/i18n/hi.json b/src/assets/i18n/hi.json
index e792de082..870cf6647 100644
--- a/src/assets/i18n/hi.json
+++ b/src/assets/i18n/hi.json
@@ -196,6 +196,9 @@
"COMMENT": "टिप्पणी",
"INTEREST_RATE": "ब्याज दर",
"BACK": "पीछे",
+ "EXIT": "बाहर निकलें",
+ "FINISH": "समाप्त करें",
+ "NEXT": "अगला",
"HELP": "मदद",
"PENALTY": "दंड",
"FROM_DATE": "प्रारंभ तिथि",
diff --git a/src/assets/i18n/ko.json b/src/assets/i18n/ko.json
index 9f49ef6cc..5fe60500d 100644
--- a/src/assets/i18n/ko.json
+++ b/src/assets/i18n/ko.json
@@ -196,6 +196,9 @@
"COMMENT": "코멘트",
"INTEREST_RATE": "이율",
"BACK": "뒤로",
+ "EXIT": "종료",
+ "FINISH": "완료",
+ "NEXT": "다음",
"HELP": "도움말",
"PENALTY": "연체료",
"FROM_DATE": "시작일",