diff --git a/README.md b/README.md index eed642d2d4..c57213a09b 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,10 @@ Mifos® X Web App is a modern single-page application (SPA) built on top of the - [OIDC](#oidc-settings) - [External National ID](#external-national-id-system-integration) - [Interbank Transfers](#interbank-transfers-settings) + - [Mifos Copilot](#mifos-copilot-settings) - [Remittance Module](#remittance-module-settings) - [Client Data Masking](#client-data-masking-example) +- [Production Mode](#production-mode) - [Interbank Transfer Menu](#interbank-transfer-menu) - [Role-Based Access Control](#role-based-access-control-rbac) - [Releases](#releases) @@ -374,14 +376,14 @@ MIFOS_PASSWORD_REGEX=^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,50}$ #### UI Display Settings -| Variable | Description | Default Value | -| ---------------------------------- | -------------------------------------------------- | ------------- | -| MIFOS_DISPLAY_TENANT_SELECTOR | Display tenant selector in Login view | true | -| MIFOS_DISPLAY_BACKEND_INFO | Display backend info in footer | true | -| MIFOS_PRODUCTION_MODE | Show minimal production hero on login page | false | -| MIFOS_ALLOW_SERVER_SWITCH_SELECTOR | Display DNS server list | true | -| MIFOS_COMPLIANCE_HIDE_CLIENT_DATA | Hide client names in UI (mask with \*) | false | -| MIFOS_PRODUCTION_MODE_ENABLE_RBAC | Enable Role-Based Access Control for menus/buttons | false | +| Variable | Description | Default Value | +| ---------------------------------- | ------------------------------------------------------------------- | ------------- | +| MIFOS_DISPLAY_TENANT_SELECTOR | Display tenant selector in Login view | true | +| MIFOS_DISPLAY_BACKEND_INFO | Display backend info in footer and Login view | false | +| MIFOS_PRODUCTION_MODE | Enable production UI mode (see [Production Mode](#production-mode)) | false | +| MIFOS_ALLOW_SERVER_SWITCH_SELECTOR | Display DNS server list | true | +| MIFOS_COMPLIANCE_HIDE_CLIENT_DATA | Hide client names in UI (mask with \*) | false | +| MIFOS_PRODUCTION_MODE_ENABLE_RBAC | Enable Role-Based Access Control for menus/buttons | false | #### OAUTH Settings @@ -446,6 +448,17 @@ For more detailed configuration options, refer to the `env.sample` file in the r | MIFOS_INTERBANK_TRANSFERS_API_VERSION | The Interbank server api version | /v1.0 | | MIFOS_INTERBANK_TRANSFERS_ENABLED | If the Interbank feature is enabled | true | +#### Mifos Copilot Settings + +These variables configure the Mifos Copilot, an AI assistant panel that lets officers operate Mifos X in natural language. Every action that writes data pauses for an explicit human confirmation. + +| Variable | Description | Default Value | +| -------------------------- | --------------------------------------------------------- | ------------- | +| MIFOS_ENABLE_COPILOT | If the Copilot panel is enabled | false | +| MIFOS_COPILOT_MCP_BASE_URL | Base URL of the Copilot gateway (empty uses mock replies) | | + +Set `MIFOS_ENABLE_COPILOT` to `true` to show the panel. With `MIFOS_COPILOT_MCP_BASE_URL` empty the panel answers from built-in mock responses and contacts no server, which is useful for demos and UI work without a gateway. Once a gateway URL is configured, the web app talks only to that gateway: it never holds an LLM key and never calls an LLM directly. The gateway keeps the key server-side and runs banking tools with the logged-in officer's own Fineract credential, so existing permissions and the audit trail still apply. + #### Remittance Module Settings These variables configure the Remittance Module, which provides a 7-step wizard for processing remittance payouts (search, validate recipient, assign payout, confirm payment, and generate receipt). @@ -483,6 +496,26 @@ M**\*** T\*\*\* This applies to client name display, e.g. in Institution/Clients list. +## Production Mode + +`MIFOS_PRODUCTION_MODE` switches the Web App into a leaner, deployment-oriented UI. It is **disabled by default** (`false`), so existing deployments keep the full development-friendly experience. + +```bash +MIFOS_PRODUCTION_MODE=true +``` + +**What changes when `MIFOS_PRODUCTION_MODE=true`:** + +| Area | Behaviour | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Login page | Shows the minimal production hero with branding only | +| Products → Loan Products → _Create_ menu | The **Create Loan Product (Convenient)** entry is hidden; **Classic** becomes the only loan product creation flow | +| Loan account → Screen Reports | The **Print** button is available on the screen report output | + +The _Convenient_ flow is the guided wizard for the pre-configured loan profiles (Personal, Two Wheeler, Education, Agriculture, Custom/Advanced), which apply opinionated defaults suited to evaluation and quick setup. In a production tenant, loan products are expected to be created through the **Classic** form, where every field is set explicitly. + +`MIFOS_PRODUCTION_MODE` and [`MIFOS_PRODUCTION_MODE_ENABLE_RBAC`](#role-based-access-control-rbac) are independent flags and can be enabled separately. + ## Interbank Transfer Menu By default, the “Interbank Transfer” menu will be displayed in the hamburger menu of the Savings account. diff --git a/docker-compose.yml b/docker-compose.yml index 1b57259e2b..5b50040e54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,3 +25,9 @@ services: - MIFOS_SESSION_IDLE_TIMEOUT=300000 - MIFOS_PRELOAD_CLIENTS=true - MIFOS_DEFAULT_CHAR_DELIMITER=, + # Mifos Copilot: off by default. Both variables are needed for real AI responses, + # MIFOS_ENABLE_COPILOT=true to show the panel and MIFOS_COPILOT_MCP_BASE_URL + # pointing at a Copilot gateway. Enabling the panel with an empty URL uses the + # built-in mock replies. + - MIFOS_ENABLE_COPILOT=false + - MIFOS_COPILOT_MCP_BASE_URL= diff --git a/env.sample b/env.sample index 746aaa8268..afacc9962d 100644 --- a/env.sample +++ b/env.sample @@ -42,10 +42,12 @@ MIFOS_HTTP_CACHE_ENABLED=true # Hide client data information (set to true to mask client names) MIFOS_COMPLIANCE_HIDE_CLIENT_DATA=false -# Production mode - when true, shows minimal hero with only branding at bottom +# Production mode - when true, shows minimal hero with only branding at bottom, +# hides the Convenient (guided) loan product creation flow so only Classic remains, +# and shows the Print button on loan screen reports # Set to true for financial institutions that want a clean, professional look - MIFOS_PRODUCTION_MODE=false + # Enable Role-Based Access Control (RBAC) for menus and buttons # When true: Menus/buttons are shown based on user permissions (production mode) # When false (default): All menus/buttons are shown (backward compatibility) diff --git a/package-lock.json b/package-lock.json index 1025e5b18e..119eb1ac57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,7 @@ "@types/d3-shape": "^3.1.0", "@types/d3-timer": "^3.0.0", "@types/jest": "^29.5.14", - "@types/leaflet": "^1.9.12", + "@types/leaflet": "^1.9.21", "@types/leaflet.markercluster": "^1.5.4", "@types/lodash": "4.17.24", "@types/node": "24.13.1", diff --git a/package.json b/package.json index bcbb90e109..96fb33b5b4 100644 --- a/package.json +++ b/package.json @@ -103,8 +103,8 @@ "@types/d3-shape": "^3.1.0", "@types/d3-timer": "^3.0.0", "@types/jest": "^29.5.14", - "@types/leaflet": "^1.9.12", "@types/leaflet.markercluster": "^1.5.4", + "@types/leaflet": "^1.9.21", "@types/lodash": "4.17.24", "@types/node": "24.13.1", "@types/vkbeautify": "^0.99.2", diff --git a/playwright.config.ts b/playwright.config.ts index bc9196a9ea..32b0df1c9a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -133,7 +133,10 @@ export default defineConfig({ name: 'unit', testMatch: [ /playwright\/utils\/.*\.spec\.ts/, - /playwright\/factories\/client\.spec\.ts/ + /playwright\/pages\/.*\.spec\.ts/, + /playwright\/fixtures\/.*\.spec\.ts/, + /playwright\/factories\/client\.spec\.ts/, + /playwright\/factories\/_shared\.spec\.ts/ ], testDir: '.', use: { storageState: { cookies: [], origins: [] } } diff --git a/playwright/auth-helpers.ts b/playwright/auth-helpers.ts index e6c88afe49..783ac5297c 100644 --- a/playwright/auth-helpers.ts +++ b/playwright/auth-helpers.ts @@ -93,6 +93,17 @@ export async function authenticateRole(role: AuthRole, page: Page, browser: Brow ); } + // Seed mifosXServerURL so SettingsService.server resolves to the + // local Fineract instance rather than falling through to + // environment.baseApiUrl (which defaults to demo.mifos.community). + // Without this, browser-side API calls like the group client + // autocomplete hit the wrong server and return empty results. + const fineractUrl = process.env.E2E_FINERACT_URL || 'https://localhost:8443'; + await page.evaluate((url) => { + localStorage.setItem('mifosXServerURL', url); + }, fineractUrl); + console.log(`[auth:${role.id}] seeded mifosXServerURL = ${fineractUrl}`); + await page.context().storageState({ path: role.storageStateFile }); console.log(`[auth:${role.id}] storageState saved to ${role.storageStateFile}`); diff --git a/playwright/config/routes.ts b/playwright/config/routes.ts index 2f97a9af6a..cdf34472f9 100644 --- a/playwright/config/routes.ts +++ b/playwright/config/routes.ts @@ -28,9 +28,62 @@ export interface AppRoutes { clientAction: (id: number, action: string) => string; clientCharges: (id: number) => string; clientChargesOverview: (id: number) => string; + /** Single client-charge detail view. */ + clientChargeView: (id: number, chargeId: number) => string; + /** Pay form for a single client charge. */ + clientChargePay: (id: number, chargeId: number) => string; + + // ── KYC tabs (nested under a client) ──────────────────────── + /** Family members list tab. */ + clientFamilyMembers: (id: number) => string; + /** Add-family-member form (a route, not a dialog). */ + clientFamilyMemberAdd: (id: number) => string; + /** Edit-family-member form. */ + clientFamilyMemberEdit: (id: number, memberId: number) => string; + /** Identifiers tab — add/delete are dialog-driven. */ + clientIdentities: (id: number) => string; + /** Documents tab — upload/delete are dialog-driven. */ + clientDocuments: (id: number) => string; + /** Notes tab — inline add form, dialog edit, confirm delete. */ + clientNotes: (id: number) => string; + /** Address tab — form is built from /fieldconfiguration/ADDRESS. */ + clientAddress: (id: number) => string; + + // ── Savings accounts (nested under a client) ────────────────────── + /** Create-savings-account stepper for a client. */ + savingsAccountCreate: (clientId: number) => string; + /** Savings account general view. */ + savingsAccountView: (clientId: number, savingsId: number) => string; + /** Savings account transactions tab. */ + savingsAccountTransactions: (clientId: number, savingsId: number) => string; + /** + * Savings account action form (Approve, Activate, Reject, + * Undo Approval, Deposit, Withdrawal, ...). + */ + savingsAccountAction: (clientId: number, savingsId: number, action: string) => string; + + // ── Loan accounts (nested under a client) ───────────────────────── + /** Create-loan-account stepper for a client. */ + loanAccountCreate: (clientId: number) => string; + /** Loan account general view. */ + loanAccountView: (clientId: number, loanId: number) => string; + /** + * Loan account action form (Approve, Disburse, Reject, + * Undo Approval, Undo Disbursal, ...). + */ + loanAccountAction: (clientId: number, loanId: number, action: string) => string; + groups: string; groupCreate: string; groupView: (id: number) => string; + /** Edit-group form. */ + groupEdit: (id: number) => string; + /** + * Group action form (Activate, Manage Members, Close, Assign Staff, + * Transfer Clients, ...). All of them mount the same + * `GroupActionsComponent`, which switches on the `:action` param. + */ + groupAction: (id: number, action: string) => string; users: string; userCreate: string; userView: (id: number) => string; @@ -47,9 +100,48 @@ export const ROUTES: AppRoutes = { clientAction: (id, action) => `/#/clients/${id}/actions/${encodeURIComponent(action)}`, clientCharges: (id) => `/#/clients/${id}/charges`, clientChargesOverview: (id) => `/#/clients/${id}/charges/overview`, + clientChargeView: (id, chargeId) => `/#/clients/${id}/charges/${chargeId}`, + clientChargePay: (id, chargeId) => `/#/clients/${id}/charges/${chargeId}/pay`, + + // The KYC tabs are children of the client view, so they share its + // resolver and the client id stays in the path. Family members is + // the only one whose add/edit are full routes rather than dialogs. + clientFamilyMembers: (id) => `/#/clients/${id}/family-members`, + clientFamilyMemberAdd: (id) => `/#/clients/${id}/family-members/add`, + clientFamilyMemberEdit: (id, memberId) => `/#/clients/${id}/family-members/${memberId}/edit`, + clientIdentities: (id) => `/#/clients/${id}/identities`, + clientDocuments: (id) => `/#/clients/${id}/documents`, + clientNotes: (id) => `/#/clients/${id}/notes`, + clientAddress: (id) => `/#/clients/${id}/address`, + + // Savings and loan accounts are lazy-loaded child modules mounted + // under the client route, so their URLs always carry the owning + // client id. Action names are interpolated into the path (Angular + // reads them as the `:name` / `:action` param) and are therefore + // encoded — "Undo Approval" and "Withdrawn by Client" both contain + // spaces. + savingsAccountCreate: (clientId) => `/#/clients/${clientId}/savings-accounts/create`, + savingsAccountView: (clientId, savingsId) => `/#/clients/${clientId}/savings-accounts/${savingsId}/general`, + savingsAccountTransactions: (clientId, savingsId) => + `/#/clients/${clientId}/savings-accounts/${savingsId}/transactions`, + savingsAccountAction: (clientId, savingsId, action) => + `/#/clients/${clientId}/savings-accounts/${savingsId}/actions/${encodeURIComponent(action)}`, + + loanAccountCreate: (clientId) => `/#/clients/${clientId}/loans-accounts/create`, + loanAccountView: (clientId, loanId) => `/#/clients/${clientId}/loans-accounts/${loanId}/general`, + loanAccountAction: (clientId, loanId, action) => + `/#/clients/${clientId}/loans-accounts/${loanId}/actions/${encodeURIComponent(action)}`, + groups: '/#/groups', groupCreate: '/#/groups/create', groupView: (id) => `/#/groups/${id}/general`, + groupEdit: (id) => `/#/groups/${id}/edit`, + // Encoded because every multi-word action name contains a space — + // "Manage Members", "Assign Staff", "Transfer Clients". Angular + // reads the decoded value back as the `:action` param and + // `GroupActionsComponent` matches it against a literal string map, + // so the encoding has to round-trip exactly. + groupAction: (id, action) => `/#/groups/${id}/actions/${encodeURIComponent(action)}`, users: '/#/appusers', userCreate: '/#/appusers/create', userView: (id) => `/#/appusers/${id}` diff --git a/playwright/config/selectors.ts b/playwright/config/selectors.ts index b3ec0eb333..fc95f31e7c 100644 --- a/playwright/config/selectors.ts +++ b/playwright/config/selectors.ts @@ -382,3 +382,833 @@ export const TRANSFER_CLIENT_SELECTORS: TransferClientSelectors = { confirmButton: 'Confirm', cancelButton: 'Cancel' }; + +// --------------------------------------------------------------------------- +// Savings account — create stepper +// --------------------------------------------------------------------------- + +/** + * Selector contract for the create-savings-account mat-stepper + * (`/#/clients/:id/savings-accounts/create`). + * + * Two structural quirks the page object has to respect, both of which + * are genuine app behaviour rather than test flakiness: + * + * - Every field on the DETAILS step except the product dropdown is + * wrapped in `@if (savingsProductSelected)`. Submitted-on does not + * exist in the DOM until a product is chosen. + * - The PREVIEW step is wrapped in `@if (savingsAccountFormValid)`, + * so its header cannot be clicked to skip ahead — the flow must + * walk DETAILS → TERMS → CHARGES via Next before Preview appears. + * + * The React port keeps the same conditional rendering, so the contract + * carries over unchanged; only the selector strings differ. + */ +export interface CreateSavingsAccountSelectors { + stepperRoot: string; + stepHeaderRole: 'tab' | 'button'; + stepLabelDetails: string; + stepLabelTerms: string; + stepLabelCharges: string; + stepLabelPreview: string; + nextButton: string; + /** + * Next button scoped to the currently selected step. + * + * A vertical mat-stepper keeps EVERY step's content in the DOM and + * animates between them, so a bare `button[matsteppernext]` matches + * one button per step. Filtering on visibility is not enough either: + * mid-transition both the outgoing and incoming steps briefly report + * visible, and a click dispatched then lands on a collapsing panel. + * Scoping to `aria-selected="true"` picks exactly one button and + * makes the click deterministic. + */ + activeStepNextButton: string; + previousButton: string; + + productDropdown: string; + submittedOnDateInput: string; + fieldOfficerDropdown: string; + externalIdInput: string; + + nominalAnnualInterestRateInput: string; + minRequiredOpeningBalanceInput: string; + + submitButton: string; + cancelButton: string; + validationError: string; +} + +export const CREATE_SAVINGS_ACCOUNT_SELECTORS: CreateSavingsAccountSelectors = { + stepperRoot: 'mat-stepper', + stepHeaderRole: 'tab', + stepLabelDetails: 'DETAILS', + stepLabelTerms: 'TERMS', + stepLabelCharges: 'CHARGES', + stepLabelPreview: 'PREVIEW', + nextButton: 'button[matsteppernext]', + activeStepNextButton: '.mat-step:has(mat-step-header[aria-selected="true"]) button[matsteppernext]', + previousButton: 'button[matstepperprevious]', + + productDropdown: 'mat-select[formcontrolname="productId"]', + submittedOnDateInput: 'input[formcontrolname="submittedOnDate"]', + fieldOfficerDropdown: 'mat-select[formcontrolname="fieldOfficerId"]', + externalIdInput: 'input[formcontrolname="externalId"]', + + nominalAnnualInterestRateInput: 'input[formcontrolname="nominalAnnualInterestRate"]', + minRequiredOpeningBalanceInput: 'input[formcontrolname="minRequiredOpeningBalance"]', + + submitButton: 'Submit', + cancelButton: 'Cancel', + validationError: 'mat-error' +}; + +// --------------------------------------------------------------------------- +// Savings account — general view +// --------------------------------------------------------------------------- + +/** + * Selector contract for the savings account general view. + * + * The action menu is driven by `SavingsButtonsConfiguration`, which + * splits entries between the top-level menu and a nested "More" + * submenu depending on status. Notably `Reject` and `Withdrawn by + * Client` live under "More" for a pending account, whereas `Approve` + * is a top-level entry — the page object hides that split behind a + * single `chooseAction()` call. + */ +export interface SavingsAccountViewSelectors { + actionsButton: string; + moreSubmenuTrigger: string; + statusBadge: string; + accountBalanceRow: string; + successSnackbar: string; + overlayBackdrop: string; + tabRole: 'tab'; +} + +export const SAVINGS_ACCOUNT_VIEW_SELECTORS: SavingsAccountViewSelectors = { + // Rendered with `[attr.aria-label]="'labels.text.Savings Account Actions' | translate"`. + actionsButton: 'button[aria-label="Savings Account Actions"]', + moreSubmenuTrigger: 'More', + statusBadge: '.status-dot', + accountBalanceRow: 'table.account-overview', + successSnackbar: '.mat-mdc-snack-bar-container', + overlayBackdrop: '.cdk-overlay-backdrop', + tabRole: 'tab' +}; + +// --------------------------------------------------------------------------- +// Savings account — lifecycle action forms +// --------------------------------------------------------------------------- + +/** + * Selector contract for the savings account lifecycle action forms + * (`/#/clients/:cid/savings-accounts/:sid/actions/:name`). + * + * All four forms share one shell — an optional date input plus an + * optional note textarea and a Confirm button — so a single contract + * and a single page object cover Approve, Activate, Reject and Undo + * Approval. Undo Approval has no date field at all, which is why + * every date selector is addressed individually rather than through a + * shared `dateInput` key. + */ +export interface SavingsAccountActionSelectors { + approvedOnDateInput: string; + activatedOnDateInput: string; + rejectedOnDateInput: string; + noteInput: string; + confirmButton: string; + cancelButton: string; +} + +export const SAVINGS_ACCOUNT_ACTION_SELECTORS: SavingsAccountActionSelectors = { + approvedOnDateInput: 'input[formcontrolname="approvedOnDate"]', + activatedOnDateInput: 'input[formcontrolname="activatedOnDate"]', + rejectedOnDateInput: 'input[formcontrolname="rejectedOnDate"]', + noteInput: 'textarea[formcontrolname="note"]', + confirmButton: 'Confirm', + cancelButton: 'Cancel' +}; + +// --------------------------------------------------------------------------- +// Savings account — deposit / withdrawal transaction form +// --------------------------------------------------------------------------- + +/** + * Selector contract for the savings deposit/withdrawal form. + * + * This form is itself a three-step linear mat-stepper — details, + * confirmation, completion — so submitting a deposit takes a Next + * click followed by a Submit click. Skipping the confirmation step is + * not possible; the stepper is `linear`. + * + * The amount field is rendered by the shared `mifosx-input-amount` + * component, which binds via `[formControl]` rather than + * `formControlName`. There is no `formcontrolname` attribute to target, + * hence the component-scoped selector. + */ +export interface SavingsTransactionSelectors { + transactionDateInput: string; + transactionAmountInput: string; + paymentTypeDropdown: string; + noteInput: string; + nextButton: string; + submitButton: string; + /** + * Button on the third "Transaction Complete" step. + * + * The form does NOT navigate away on submit — it advances to a + * receipt panel and waits for the user. Clicking Done is what routes + * back to the transactions tab. + */ + doneButton: string; + /** Heading rendered on the completion step once the post succeeds. */ + successHeading: string; + cancelButton: string; +} + +export const SAVINGS_TRANSACTION_SELECTORS: SavingsTransactionSelectors = { + transactionDateInput: 'input[formcontrolname="transactionDate"]', + transactionAmountInput: 'mifosx-input-amount input', + paymentTypeDropdown: 'mat-select[formcontrolname="paymentTypeId"]', + noteInput: 'textarea[formcontrolname="note"]', + nextButton: 'Next', + submitButton: 'Submit', + doneButton: 'Done', + successHeading: 'Transaction Successful', + cancelButton: 'Cancel' +}; + +// --------------------------------------------------------------------------- +// Loan account — create stepper +// --------------------------------------------------------------------------- + +/** + * Selector contract for the create-loan-account mat-stepper + * (`/#/clients/:id/loans-accounts/create`). + * + * Harder than its savings counterpart in three specific ways: + * + * - The product dropdown embeds an `ngx-mat-select-search` box, and + * its `mat-option` values are the product SHORT NAME while the + * visible label is the product name prefixed by product type. A + * plain option click can therefore miss; the page object types into + * the search box first. + * - TERMS, CHARGES, REPAYMENT SCHEDULE and PREVIEW are all wrapped in + * `@if (productId)` / `@if (loansAccountFormValid)`, so none of them + * exist in the DOM until a product is selected and the form is + * valid. + * - REPAYMENT SCHEDULE is populated from a server round-trip + * (`/loans/template` → calculate schedule). Clicking through before + * it resolves lands on an empty table, so the page object waits on + * a populated schedule row rather than on the step header. + * + * The principal field uses the shared `mifosx-input-amount` component, + * so — like the savings transaction amount — it has no + * `formcontrolname` attribute. + */ +export interface CreateLoanAccountSelectors { + stepperRoot: string; + stepHeaderRole: 'tab' | 'button'; + stepLabelDetails: string; + stepLabelTerms: string; + stepLabelCharges: string; + stepLabelSchedule: string; + stepLabelPreview: string; + nextButton: string; + /** See `CreateSavingsAccountSelectors.activeStepNextButton`. */ + activeStepNextButton: string; + previousButton: string; + + productDropdown: string; + productSearchInput: string; + submittedOnDateInput: string; + expectedDisbursementDateInput: string; + externalIdInput: string; + loanOfficerDropdown: string; + loanPurposeDropdown: string; + + principalInput: string; + loanTermFrequencyInput: string; + numberOfRepaymentsInput: string; + repaymentEveryInput: string; + interestRatePerPeriodInput: string; + + scheduleTable: string; + scheduleRow: string; + /** Accessible name of the button that requests the schedule. */ + generateScheduleButton: string; + + submitButton: string; + cancelButton: string; + validationError: string; +} + +export const CREATE_LOAN_ACCOUNT_SELECTORS: CreateLoanAccountSelectors = { + stepperRoot: 'mat-stepper', + stepHeaderRole: 'tab', + stepLabelDetails: 'DETAILS', + stepLabelTerms: 'TERMS', + stepLabelCharges: 'CHARGES', + stepLabelSchedule: 'REPAYMENT SCHEDULE', + stepLabelPreview: 'PREVIEW', + nextButton: 'button[matsteppernext]', + activeStepNextButton: '.mat-step:has(mat-step-header[aria-selected="true"]) button[matsteppernext]', + previousButton: 'button[matstepperprevious]', + + productDropdown: 'mat-select[formcontrolname="productId"]', + // Rendered by `ngx-mat-select-search` inside the select's overlay. + productSearchInput: '.mat-mdc-select-panel input.mat-mdc-input-element, ngx-mat-select-search input', + submittedOnDateInput: 'input[formcontrolname="submittedOnDate"]', + expectedDisbursementDateInput: 'input[formcontrolname="expectedDisbursementDate"]', + externalIdInput: 'input[formcontrolname="externalId"]', + loanOfficerDropdown: 'mat-select[formcontrolname="loanOfficerId"]', + loanPurposeDropdown: 'mat-select[formcontrolname="loanPurposeId"]', + + principalInput: 'mifosx-input-amount input', + loanTermFrequencyInput: 'input[formcontrolname="loanTermFrequency"]', + numberOfRepaymentsInput: 'input[formcontrolname="numberOfRepayments"]', + repaymentEveryInput: 'input[formcontrolname="repaymentEvery"]', + interestRatePerPeriodInput: 'input[formcontrolname="interestRatePerPeriod"]', + + scheduleTable: 'mifosx-loans-account-schedule-step table', + scheduleRow: 'mifosx-loans-account-schedule-step table tbody tr', + generateScheduleButton: 'Generate Repayment Schedule', + + submitButton: 'Submit', + cancelButton: 'Cancel', + validationError: 'mat-error' +}; + +// --------------------------------------------------------------------------- +// Loan account — general view +// --------------------------------------------------------------------------- + +/** + * Selector contract for the loan account general view. + * + * Like the savings view, the action menu splits between top-level + * buttons and nested "Payments" / "More" submenus depending on status. + * `Approve` and `Reject` are top-level for a pending loan; `Disburse`, + * `Undo Approval` and `Undo Disbursal` are top-level for approved and + * active loans respectively. + */ +export interface LoanAccountViewSelectors { + actionsButton: string; + moreSubmenuTrigger: string; + paymentsSubmenuTrigger: string; + statusBadge: string; + successSnackbar: string; + overlayBackdrop: string; + tabRole: 'tab'; +} + +export const LOAN_ACCOUNT_VIEW_SELECTORS: LoanAccountViewSelectors = { + // Rendered with `[attr.aria-label]="'labels.text.Loan Account Actions' | translate"`. + actionsButton: 'button[aria-label="Loan Account Actions"]', + moreSubmenuTrigger: 'More', + paymentsSubmenuTrigger: 'Payments', + statusBadge: '.status-dot', + successSnackbar: '.mat-mdc-snack-bar-container', + overlayBackdrop: '.cdk-overlay-backdrop', + tabRole: 'tab' +}; + +// --------------------------------------------------------------------------- +// Loan account — lifecycle action forms +// --------------------------------------------------------------------------- + +/** + * Selector contract for the loan account lifecycle action forms + * (`/#/clients/:cid/loans-accounts/:lid/actions/:action`). + * + * Note the submit control is labelled **Submit** here, whereas the + * savings action forms label the equivalent control **Confirm**. That + * inconsistency is in the app, not in this contract — encoding it here + * keeps it out of the specs. + * + * Undo Approval and Undo Disbursal render no form fields at all, just + * a confirmation button. + */ +export interface LoanAccountActionSelectors { + approvedOnDateInput: string; + expectedDisbursementDateInput: string; + actualDisbursementDateInput: string; + rejectedOnDateInput: string; + amountInput: string; + noteInput: string; + submitButton: string; + cancelButton: string; +} + +export const LOAN_ACCOUNT_ACTION_SELECTORS: LoanAccountActionSelectors = { + approvedOnDateInput: 'input[formcontrolname="approvedOnDate"]', + expectedDisbursementDateInput: 'input[formcontrolname="expectedDisbursementDate"]', + actualDisbursementDateInput: 'input[formcontrolname="actualDisbursementDate"]', + rejectedOnDateInput: 'input[formcontrolname="rejectedOnDate"]', + amountInput: 'mifosx-input-amount input', + noteInput: 'textarea[formcontrolname="note"]', + submitButton: 'Submit', + cancelButton: 'Cancel' +}; + +// ───────────────────────────────────────────────────────────────────── +// Client charges +// ───────────────────────────────────────────────────────────────────── + +/** + * Add Charge form (`/clients/:id/actions/Add Charge`). + * + * ── The shape of this form is entirely data-driven ────────────────── + * + * Only the charge dropdown exists on first render. Everything below it + * sits behind `@if (chargeDetails)`, which is populated by a + * `GET /charges/{id}?template=true` fired from the dropdown's + * `valueChanges`. A page object that fills `amount` straight after + * navigating will therefore miss — the control is not merely disabled, + * it is not in the DOM. + * + * Which date control appears depends on the selected definition's + * charge time type, and the component decides by comparing the + * *English* `chargeTimeType.value` string: + * + * - Specified due date → `dueDate` + * - Annual / Monthly → `feeOnMonthDay` + * - Monthly only → plus `feeInterval` + * - Withdrawal / No-activity fee → no date control at all + * + * `chargeCalculationType` and `chargeTimeType` render as selects but + * are permanently disabled — they are display-only echoes of the + * definition, so specs assert on them rather than set them. + */ +export interface AddChargeSelectors { + chargeDropdown: string; + amountInput: string; + chargeCalculationTypeDropdown: string; + chargeTimeTypeDropdown: string; + /** Rendered only for specified-due-date charges. */ + dueDateInput: string; + /** Rendered only for annual/monthly charges. */ + feeOnMonthDayInput: string; + /** Rendered only for monthly charges. */ + feeIntervalInput: string; + submitButton: string; + cancelButton: string; +} + +export const ADD_CHARGE_SELECTORS: AddChargeSelectors = { + chargeDropdown: 'mat-select[formcontrolname="chargeId"]', + amountInput: 'input[formcontrolname="amount"]', + chargeCalculationTypeDropdown: 'mat-select[formcontrolname="chargeCalculationType"]', + chargeTimeTypeDropdown: 'mat-select[formcontrolname="chargeTimeType"]', + dueDateInput: 'input[formcontrolname="dueDate"]', + feeOnMonthDayInput: 'input[formcontrolname="feeOnMonthDay"]', + feeIntervalInput: 'input[formcontrolname="feeInterval"]', + submitButton: 'Submit', + cancelButton: 'Cancel' +}; + +/** + * The "Upcoming Charges" table on the client General tab, plus the + * charge detail and pay views it links to. + * + * The table's per-row buttons carry no accessible text — they are + * `` / `fa-flag` inside a bare button — so they + * are addressed by `matTooltip`, which is the only stable, translated + * handle the markup offers. + * + * Both row buttons call `routeEdit($event)` to stop propagation, + * because the row itself is a `routerLink` to the charge detail view. + * Miss the button and you silently navigate instead of acting. + */ +export interface ClientChargesSelectors { + upcomingChargesTable: string; + chargeRow: string; + payRowButton: string; + waiveRowButton: string; + chargesOverviewLink: string; + overviewTable: string; + overviewRow: string; + /** Shown in place of the table when the client has no charges. */ + emptyBanner: string; +} + +export const CLIENT_CHARGES_SELECTORS: ClientChargesSelectors = { + upcomingChargesTable: 'mifosx-general-tab table.data-table', + chargeRow: 'mifosx-general-tab table.data-table tbody tr', + // Class-only, not `ng-reflect-message` — Angular strips `ng-reflect-*` + // in production/optimized builds, so the tooltip-based match is not + // stable. `.row-action.primary` is the rendered class pair, matching + // the `waiveRowButton` pattern below. + payRowButton: 'button.row-action.primary', + waiveRowButton: 'button.row-action:not(.primary)', + chargesOverviewLink: 'Charges Overview', + overviewTable: 'mifosx-charges-overview table', + overviewRow: 'mifosx-charges-overview table tbody tr', + emptyBanner: '.info-banner' +}; + +/** + * Charge detail view (`/clients/:id/charges/:chargeId`) and its pay + * form (`.../pay`). + * + * Delete and Waive fire immediately with no confirmation dialog, which + * is worth knowing before writing a spec that waits for one. + */ +export interface ChargeViewSelectors { + payButton: string; + waiveButton: string; + deleteButton: string; + backButton: string; + /** Pay form fields. */ + payAmountInput: string; + payDateInput: string; + paySubmitButton: string; +} + +export const CHARGE_VIEW_SELECTORS: ChargeViewSelectors = { + payButton: 'Pay', + waiveButton: 'Waive Charge', + deleteButton: 'Delete', + backButton: 'Back', + payAmountInput: 'input[formcontrolname="amount"]', + payDateInput: 'input[formcontrolname="transactionDate"]', + paySubmitButton: 'Submit' +}; + +// ───────────────────────────────────────────────────────────────────── +// Client KYC tabs +// ───────────────────────────────────────────────────────────────────── + +/** + * Family members tab and its add/edit forms. + * + * Unlike every other KYC tab, add and edit here are **routes** + * (`/family-members/add`, `/family-members/:id/edit`) rather than + * dialogs. The list itself is a `mat-accordion`: the Edit and Delete + * buttons live inside each panel's body and are not in the DOM until + * that panel is expanded. + * + * The relationship / gender / profession / marital status dropdowns are + * filled from the family-member template endpoint, so a tenant with no + * configured code values renders them empty — relationship and gender + * are `required`, which makes that a hard blocker rather than a + * cosmetic gap. + */ +export interface FamilyMembersSelectors { + addButton: string; + panel: string; + panelHeader: string; + panelTitle: string; + editButton: string; + deleteButton: string; + /** Add/edit form fields. */ + firstNameInput: string; + middleNameInput: string; + lastNameInput: string; + qualificationInput: string; + ageInput: string; + isDependentCheckbox: string; + relationshipDropdown: string; + genderDropdown: string; + professionDropdown: string; + maritalStatusDropdown: string; + dateOfBirthInput: string; + submitButton: string; + cancelButton: string; +} + +export const FAMILY_MEMBERS_SELECTORS: FamilyMembersSelectors = { + addButton: 'Add', + panel: 'mat-expansion-panel.family-member', + panelHeader: 'mat-expansion-panel-header', + panelTitle: 'mat-panel-title', + editButton: 'Edit', + deleteButton: 'Delete', + firstNameInput: 'input[formcontrolname="firstName"]', + middleNameInput: 'input[formcontrolname="middleName"]', + lastNameInput: 'input[formcontrolname="lastName"]', + qualificationInput: 'input[formcontrolname="qualification"]', + ageInput: 'input[formcontrolname="age"]', + isDependentCheckbox: 'mat-checkbox[formcontrolname="isDependent"]', + relationshipDropdown: 'mat-select[formcontrolname="relationshipId"]', + genderDropdown: 'mat-select[formcontrolname="genderId"]', + professionDropdown: 'mat-select[formcontrolname="professionId"]', + maritalStatusDropdown: 'mat-select[formcontrolname="maritalStatusId"]', + dateOfBirthInput: 'input[formcontrolname="dateOfBirth"]', + submitButton: 'Submit', + cancelButton: 'Cancel' +}; + +/** + * Identifiers tab. Add is dialog-driven via the *shared* + * `UploadDocumentDialogComponent`, which serves double duty for + * documents — hence `documentIdentifier` toggling four extra controls + * into the same form. + * + * `fileName` is marked `required` on that shared form even in + * identifier mode, where no file is actually needed: the component only + * uploads when `response.file` is set. So a spec must fill `fileName` + * to enable the submit button, but need not attach anything. + * + * Delete goes through the generic confirm dialog whose affirmative + * button is labelled **Confirm**, not "Delete". + */ +export interface ClientIdentifiersSelectors { + addButton: string; + table: string; + row: string; + deleteRowButton: string; + /** Dialog fields. */ + documentTypeDropdown: string; + statusDropdown: string; + documentKeyInput: string; + descriptionInput: string; + fileNameInput: string; + dialogSubmitButton: string; + confirmDeleteButton: string; +} + +export const CLIENT_IDENTIFIERS_SELECTORS: ClientIdentifiersSelectors = { + addButton: 'Add', + table: 'mifosx-identities-tab table', + row: 'mifosx-identities-tab table tbody tr', + deleteRowButton: 'button.identity-action-button', + documentTypeDropdown: 'mat-select[formcontrolname="documentTypeId"]', + statusDropdown: 'mat-select[formcontrolname="status"]', + documentKeyInput: 'input[formcontrolname="documentKey"]', + descriptionInput: 'input[formcontrolname="description"]', + fileNameInput: 'input[formcontrolname="fileName"]', + dialogSubmitButton: 'Add', + confirmDeleteButton: 'Confirm' +}; + +/** + * Notes tab, rendered by the shared `mifosx-entity-notes-tab`. + * + * Add is an inline form at the top of the tab, not a dialog — its + * submit button is labelled "Add" and is disabled until the textarea + * is non-empty. + * + * Edit opens a generic `FormDialogComponent` whose affirmative button + * is labelled **Confirm**, and which stays disabled while the form is + * `pristine` — so a spec must actually change the text, not just + * retype the same value. Delete goes through the generic delete + * dialog, whose affirmative button is also "Confirm". + * + * Note `editDialogInput` is an accessible *name*, not a CSS selector. + * The generic dialog builds its controls through `mifosx-formfield`, + * which binds `[formControlName]` as a property — so unlike every + * hand-written form in this app it emits no `formcontrolname` + * attribute to match on. The `mat-label` is the only stable handle. + */ +export interface ClientNotesSelectors { + noteInput: string; + addButton: string; + noteItem: string; + noteContent: string; + editButton: string; + deleteButton: string; + /** Accessible name of the text input inside the edit dialog. */ + editDialogInput: string; + confirmButton: string; +} + +export const CLIENT_NOTES_SELECTORS: ClientNotesSelectors = { + noteInput: 'textarea[formcontrolname="note"]', + addButton: 'Add', + noteItem: '.note-card', + noteContent: '.note-content', + editButton: 'Edit', + deleteButton: 'Delete', + editDialogInput: 'Note', + confirmButton: 'Confirm' +}; + +// --------------------------------------------------------------------------- +// Group — create form +// --------------------------------------------------------------------------- + +/** + * Create-group form (`/#/groups/create`). + * + * Unlike the client create flow this is a **plain form, not a stepper** — + * every control is on screen at once and Submit is simply + * `[disabled]="!groupForm.valid"`. + * + * ── Two controls that are not always in the DOM ───────────────────── + * + * `activationDate` is added to the form group *and* rendered only while + * the `active` checkbox is checked (`@if (groupForm.controls.active.value)`). + * Querying it before checking the box yields zero elements, not a + * hidden one. + * + * `staffId` is populated from an office-scoped lookup and is + * `disable()`d outright when the selected office has no staff — so a + * disabled staff dropdown is normal, not a defect. + * + * ── The client autocomplete ───────────────────────────────────────── + * + * The search input is bound with `[formControl]="clientChoice"`, a + * property binding, so **no `formcontrolname` attribute reaches the + * DOM**. It is matched instead on `role="combobox"`, which + * `MatAutocompleteTrigger` sets as a host binding; scoping to + * `mifosx-create-group` keeps it unambiguous. + * + * The search only fires once at least 2 characters are typed, and it + * passes `officeId` from the form plus `orphansOnly=true` — so an + * office must be selected first, and clients already in a group never + * appear. + * + * ── Icon-only buttons ─────────────────────────────────────────────── + * + * `addClientButton` and `removeClientButton` contain nothing but a + * ``: no text, no `aria-label`, no `matTooltip`. They have no + * accessible name at all, so structural CSS is the only option here. + * These two entries are the most brittle in this file; giving the app + * buttons real `aria-label`s would let both become role+name lookups. + */ +export interface CreateGroupSelectors { + nameInput: string; + officeDropdown: string; + staffDropdown: string; + submittedOnDateInput: string; + activeCheckbox: string; + /** Only present in the DOM while `active` is checked. */ + activationDateInput: string; + externalIdInput: string; + /** Client autocomplete search box — matched on `role`, not name. */ + clientSearchInput: string; + /** Structural: icon-only button, no accessible name. */ + addClientButton: string; + selectedClientItem: string; + /** Structural: icon-only button, no accessible name. */ + removeClientButton: string; + submitButton: string; + cancelButton: string; +} + +export const CREATE_GROUP_SELECTORS: CreateGroupSelectors = { + nameInput: 'input[formcontrolname="name"]', + officeDropdown: 'mat-select[formcontrolname="officeId"]', + staffDropdown: 'mat-select[formcontrolname="staffId"]', + submittedOnDateInput: 'input[formcontrolname="submittedOnDate"]', + activeCheckbox: 'mat-checkbox[formcontrolname="active"]', + activationDateInput: 'input[formcontrolname="activationDate"]', + externalIdInput: 'input[formcontrolname="externalId"]', + clientSearchInput: 'mifosx-create-group input[role="combobox"]', + addClientButton: 'mifosx-create-group .mat-table .mat-header-row button', + selectedClientItem: 'mifosx-create-group mat-nav-list div[mat-list-item]', + removeClientButton: 'mifosx-create-group mat-nav-list div[mat-list-item] button', + submitButton: 'Submit', + cancelButton: 'Cancel' +}; + +// --------------------------------------------------------------------------- +// Group — view / general tab +// --------------------------------------------------------------------------- + +/** + * Group view shell (`/#/groups/:id/general`) and its General tab. + * + * ── Status is not rendered as text ────────────────────────────────── + * + * The header shows status only as a CSS class derived from a + * `statusLookup` pipe plus a `matTooltip`, neither of which is a + * reliable assertion target. The **menu contents** are, though: the + * "Activate" item is wrapped in + * `@if (!(groupViewData.status.value === 'Active'))`, so its presence + * or absence is an exact, text-based Active-vs-Pending signal. That is + * what `activateMenuItem` is for. + * + * ── A swapped-permission bug lives in this menu ───────────────────── + * + * "Manage Members" is guarded by `TRANSFERCLIENTS_GROUP` and "Transfer + * Clients" by `ASSOCIATECLIENTS_GROUP` — the two are the wrong way + * round. It has no effect while RBAC is disabled (which it is in every + * deployed environment today), so it is recorded here rather than + * asserted; the RBAC work is where it can actually be pinned. + * + * ── The General tab renders several tables ────────────────────────── + * + * Client members, loan accounts, savings, GSIM and GLIM all render + * `table[mat-table]`. The members table is the first, and the whole + * block is `@if (groupClientMembers)`-guarded so it is *absent*, not + * empty, for a group with no members. + */ +export interface GroupViewSelectors { + /** Accessible name (an `aria-label`) of the actions menu trigger. */ + actionsMenuButton: string; + /** Menu item — absent once the group is Active. */ + activateMenuItem: string; + manageMembersMenuItem: string; + editMenuItem: string; + groupNameHeading: string; + /** First `table[mat-table]` on the General tab; absent when memberless. */ + clientMembersTable: string; + clientMembersRow: string; +} + +export const GROUP_VIEW_SELECTORS: GroupViewSelectors = { + actionsMenuButton: 'Group actions', + activateMenuItem: 'Activate', + manageMembersMenuItem: 'Manage Members', + editMenuItem: 'Edit', + groupNameHeading: 'mifosx-groups-view mat-card-title h3', + clientMembersTable: 'mifosx-general-tab table[mat-table]', + clientMembersRow: 'tbody tr' +}; + +// --------------------------------------------------------------------------- +// Group — manage members action +// --------------------------------------------------------------------------- + +/** + * Manage-members action screen + * (`/#/groups/:id/actions/Manage%20Members`). + * + * Same autocomplete and icon-only-button traps as the create form — + * see {@link CreateGroupSelectors} — but two differences matter. + * + * ── Writes go straight to the server ──────────────────────────────── + * + * Here `addClient()` / `removeClient()` POST + * `?command=associateClients` / `disassociateClients` immediately, + * whereas the create form only stages members in memory until Submit. + * + * ── The member list is stale after a write ────────────────────────── + * + * `ManageGroupMembersComponent` is `OnPush` and mutates + * `clientMembers` with `push`/`splice` inside the **async subscribe of + * the HTTP response**. Nothing marks the view dirty at that point, so + * the rendered list can lag behind a write that genuinely succeeded. + * Page objects therefore reload before asserting, and the reload is + * deliberately visible rather than hidden. + * + * ── Removal is dialog-confirmed ───────────────────────────────────── + * + * `removeMemberButton` opens the shared `DeleteDialogComponent`, whose + * affirmative button is labelled **"Confirm"**, not "Delete". That + * dialog also reads `response.delete` with no null guard, so + * dismissing it via ESC or a backdrop click throws — always click one + * of the two buttons. + */ +export interface ManageGroupMembersSelectors { + /** Client autocomplete search box — matched on `role`, not name. */ + clientSearchInput: string; + /** Structural: icon-only button, no accessible name. */ + addClientButton: string; + memberItem: string; + /** Structural: icon-only, carries a `matTooltip` but no label. */ + removeMemberButton: string; + confirmButton: string; +} + +export const MANAGE_GROUP_MEMBERS_SELECTORS: ManageGroupMembersSelectors = { + clientSearchInput: 'mifosx-manage-group-members input[role="combobox"]', + addClientButton: 'mifosx-manage-group-members .mat-table .mat-header-row button', + memberItem: 'mifosx-manage-group-members mat-nav-list div[mat-list-item]', + removeMemberButton: 'mifosx-manage-group-members mat-nav-list div[mat-list-item] button', + confirmButton: 'Confirm' +}; diff --git a/playwright/factories/_shared.spec.ts b/playwright/factories/_shared.spec.ts new file mode 100644 index 0000000000..8bb12312ef --- /dev/null +++ b/playwright/factories/_shared.spec.ts @@ -0,0 +1,63 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Unit coverage for the pure helpers in `_shared.ts`. + * + * `resolveDefaultOfficeId` needs an `ApiSetupManager` and is exercised + * by the integration specs; `resolveProductId` is pure, so it is + * pinned here where it costs nothing to run. + */ + +import { test, expect } from '@playwright/test'; +import { resolveProductId } from './_shared'; + +test.describe('resolveProductId', () => { + test('reads the `id` spelling returned by ensure*Product', () => { + expect(resolveProductId({ id: 42, name: 'E2E Loan Product' }, 'loan.factory')).toBe(42); + }); + + test('reads the `resourceId` spelling returned by create-* envelopes', () => { + expect(resolveProductId({ resourceId: 7 }, 'savings.factory')).toBe(7); + }); + + test('prefers `id` when a payload carries both spellings', () => { + // Fineract product-list rows carry `id`; a create envelope merged on + // top could add `resourceId`. `id` is the canonical product key. + expect(resolveProductId({ id: 3, resourceId: 99 }, 'loan.factory')).toBe(3); + }); + + test('accepts id 0 rather than treating it as absent', () => { + // Guards against a `||` regression — 0 is falsy but a valid id. + expect(resolveProductId({ id: 0 }, 'loan.factory')).toBe(0); + }); + + test('falls back to a numeric resourceId when id is present but not numeric', () => { + // `id ?? resourceId` would latch onto the non-numeric `id` and throw, + // discarding a perfectly good `resourceId`. Selection must be by type, + // not merely by presence. + expect(resolveProductId({ id: 'invalid', resourceId: 7 }, 'savings.factory')).toBe(7); + }); + + test('throws a caller-tagged error when neither spelling is numeric', () => { + // The silent failure this replaces was an account POSTed with + // `productId: undefined`, which Fineract rejects with an opaque 400. + expect(() => resolveProductId({ name: 'no id here' }, 'savings.factory')).toThrow( + /savings\.factory: product payload missing a numeric id\/resourceId/ + ); + }); + + test('throws on a string id instead of silently coercing it', () => { + expect(() => resolveProductId({ id: '42' }, 'loan.factory')).toThrow(/loan\.factory/); + }); + + test('throws on null and undefined payloads', () => { + expect(() => resolveProductId(null, 'loan.factory')).toThrow(/loan\.factory/); + expect(() => resolveProductId(undefined, 'loan.factory')).toThrow(/loan\.factory/); + }); +}); diff --git a/playwright/factories/_shared.ts b/playwright/factories/_shared.ts index 5e356b161d..e7e93852c8 100644 --- a/playwright/factories/_shared.ts +++ b/playwright/factories/_shared.ts @@ -38,3 +38,35 @@ export const FIRST_OFFICE_CACHE_KEY = 'office:first'; export async function resolveDefaultOfficeId(setup: ApiSetupManager): Promise { return setup.dedupe(FIRST_OFFICE_CACHE_KEY, () => setup.api.getFirstOfficeId()); } + +/** + * Normalise a Fineract product payload down to its numeric id. + * + * `ensureMinimalLoanProduct` / `ensureMinimalSavingsProduct` return + * either an existing product straight off the product-list endpoint or + * a freshly-built `{ id, name, shortName }` projection. Both spell the + * key `id`, whereas the create-* envelope used everywhere else in this + * suite spells it `resourceId` — so a factory that reaches for + * `product.resourceId` silently gets `undefined` and posts an account + * with no product attached. Reading both spellings in one place stops + * every future product-backed factory from re-discovering that. + * + * @param product - Payload returned by an `ensure*Product` call. + * @param caller - Factory name, used to make the failure traceable. + * @returns The numeric product id. + * @throws When neither `id` nor `resourceId` is a number. + */ +export function resolveProductId(product: unknown, caller: string): number { + const candidate = product as { id?: unknown; resourceId?: unknown } | null | undefined; + // Prefer a numeric `id`, then fall back to `resourceId`. Selecting on + // nullishness alone (`id ?? resourceId`) would latch onto a + // present-but-non-numeric `id` and reject a payload whose `resourceId` + // is a perfectly good number. + const id = typeof candidate?.id === 'number' ? candidate.id : candidate?.resourceId; + + if (typeof id !== 'number') { + throw new Error(`${caller}: product payload missing a numeric id/resourceId, got ${JSON.stringify(product)}`); + } + + return id; +} diff --git a/playwright/factories/charge.factory.ts b/playwright/factories/charge.factory.ts new file mode 100644 index 0000000000..a2fc5aff30 --- /dev/null +++ b/playwright/factories/charge.factory.ts @@ -0,0 +1,210 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Factory for Fineract client charges owned by the current Playwright + * test. + * + * ── The two-level model, which is the whole point of this file ────── + * + * Fineract separates a charge *definition* (global, reusable: name, + * currency, amount, when it applies) from a *client charge* (one + * definition attached to one client, with its own paid/waived state). + * The UI collapses these into one "Add Charge" form, which makes it + * easy to write a spec that creates a client charge and never notices + * the definition it depends on. + * + * That dependency is not optional. The Add Charge dropdown is filled + * from definitions with `chargeAppliesTo: 3` (Client); with none + * seeded the dropdown renders empty, the conditional fields below it + * never appear, and the form is simply unreachable. So every charge + * spec goes through {@link ensureClientChargeDefinition} first. + * + * ── Cleanup ───────────────────────────────────────────────────────── + * + * Client charges delete cleanly while unpaid, so the deleter here + * normally succeeds — unlike the loan/savings factories. A *paid* + * charge cannot be deleted; that deleter is registered anyway and its + * failure is recorded by the `CleanupGuard` without throwing, matching + * the account factories' behaviour. + * + * Definitions are deliberately NOT registered for cleanup. They are + * shared infrastructure keyed by a fixed name, exactly like the + * minimal loan/savings products. + * + * Portability note: zero Angular/Playwright-page imports. The React + * port can adopt this file verbatim. + */ + +import type { ApiSetupManager } from '../utils/api-setup-manager'; +import type { CleanupGuard } from '../utils/cleanup-guard'; +import { DEFAULT_ACCOUNT_OPENING_DATE } from './client.factory'; + +/** + * Names of the shared charge definitions seeded for E2E runs. + * + * Exported so UI specs can pick the same definition from the Add + * Charge dropdown that the API factories attach by id. The dropdown + * renders `" ()"`, so specs should match by + * substring rather than exact text. + */ +export const E2E_CHARGE_NAMES = { + /** Specified-due-date charge — renders a `dueDate` control. */ + specifiedDueDate: 'E2E Client Charge' +} as const; + +/** + * Fineract charge time type ids. + * + * ── Only `specifiedDueDate` is valid for client charges ───────────── + * + * The charges template endpoint advertises all sixteen time types + * regardless of what the definition applies to, so `annual` and + * `monthly` look available and the Add Charge component even has + * branches for them (`feeOnMonthDay`, `feeInterval`). They are not + * reachable: creating a definition with `chargeAppliesTo: 3` and any + * other time type is rejected with + * + * The parameter `chargeTimeType` must be one of [ 2 ] . + * + * The other ids are kept here as documentation of that gap — the + * annual/monthly branches of the Add Charge form are dead code for + * client charges, and a test asserting on them can never pass against + * a real backend. + */ +export const CHARGE_TIME_TYPE = { + /** The only time type Fineract accepts for client charges. */ + specifiedDueDate: 2, + /** Savings/loan charges only — rejected for `chargeAppliesTo: 3`. */ + annual: 6, + /** Savings/loan charges only — rejected for `chargeAppliesTo: 3`. */ + monthly: 7 +} as const; + +/** Default flat amount for seeded client charge definitions. */ +export const DEFAULT_CHARGE_AMOUNT = 100; + +/** A client charge created by this factory. */ +export interface TestClientCharge { + /** Fineract client charge id. */ + resourceId: number; + /** Owning client id. */ + clientId: number; + /** Charge definition id this charge was created from. */ + chargeId: number; + /** Definition name, as shown in the UI. */ + name: string; + /** Charge amount. */ + amount: number; + /** Due date in `dd MMMM yyyy` wire format. */ + dueDate: string; +} + +/** Caller-supplied tweaks accepted by {@link createTestClientCharge}. */ +export interface CreateTestClientChargeOverrides { + /** Charge definition id. Defaults to the shared specified-due-date definition. */ + chargeId?: number; + /** Definition name used when seeding. Ignored if `chargeId` is given. */ + chargeName?: string; + /** Fineract charge time type id. Ignored if `chargeId` is given. */ + chargeTimeType?: number; + /** Due date, `dd MMMM yyyy`. Defaults to {@link DEFAULT_ACCOUNT_OPENING_DATE}. */ + dueDate?: string; + /** Amount override. Defaults to {@link DEFAULT_CHARGE_AMOUNT}. */ + amount?: number; +} + +/** + * Seed (or reuse) a client-applicable charge definition. + * + * Thin wrapper over the API client so specs and page objects have one + * obvious entry point, and so the default name/time-type pairing lives + * in exactly one place. + * + * @param setup - The per-test {@link ApiSetupManager}. + * @param name - Definition name. Must be unique per time type. + * @param chargeTimeType - Fineract charge time type id. + * @param amount - Flat amount for the definition. + * @returns The charge definition payload. + */ +export async function ensureClientChargeDefinition( + setup: ApiSetupManager, + name: string = E2E_CHARGE_NAMES.specifiedDueDate, + chargeTimeType: number = CHARGE_TIME_TYPE.specifiedDueDate, + amount: number = DEFAULT_CHARGE_AMOUNT +): Promise { + return setup.api.ensureClientChargeDefinition(name, chargeTimeType, amount); +} + +/** + * Attach a charge to a client via the API. + * + * Used to arrange state for the *operations* specs (pay / waive / + * delete), which need an existing charge and should not re-test the + * Add Charge form to get one. + * + * @param setup - The per-test {@link ApiSetupManager}. + * @param guard - The per-test {@link CleanupGuard}. + * @param clientId - resourceId of the owning client. + * @param overrides - See {@link CreateTestClientChargeOverrides}. + */ +export async function createTestClientCharge( + setup: ApiSetupManager, + guard: CleanupGuard, + clientId: number, + overrides: CreateTestClientChargeOverrides = {} +): Promise { + // A caller who supplies `chargeId` bypasses the definition lookup, so + // this factory cannot know the definition's real name. Require + // `chargeName` alongside it rather than return the default name, which + // could differ from the attached charge — specs select/assert on `name`. + if (overrides.chargeId !== undefined && overrides.chargeName === undefined) { + throw new Error( + 'createTestClientCharge: pass `chargeName` alongside `chargeId` so the returned name matches the attached definition' + ); + } + + const name = overrides.chargeName ?? E2E_CHARGE_NAMES.specifiedDueDate; + const amount = overrides.amount ?? DEFAULT_CHARGE_AMOUNT; + const dueDate = overrides.dueDate ?? DEFAULT_ACCOUNT_OPENING_DATE; + + let chargeId = overrides.chargeId; + if (chargeId === undefined) { + const definition = await ensureClientChargeDefinition( + setup, + name, + overrides.chargeTimeType ?? CHARGE_TIME_TYPE.specifiedDueDate, + amount + ); + // `ensure` returns either a freshly created envelope (`resourceId`) + // or an existing definition read back from the list (`id`). Same + // trap as `resolveProductId` guards against for products. + chargeId = definition?.id ?? definition?.resourceId; + if (typeof chargeId !== 'number') { + throw new Error( + `createTestClientCharge: could not resolve a numeric charge definition id, got ${JSON.stringify(definition)}` + ); + } + } + + const response = await setup.api.createClientCharge(clientId, chargeId, dueDate, amount); + const resourceId: number = response?.resourceId; + if (typeof resourceId !== 'number') { + throw new Error( + `createTestClientCharge: Fineract create-client-charge response missing numeric resourceId, got ${JSON.stringify( + response + )}` + ); + } + + guard.register(`clientCharge:${resourceId}`, async () => { + await setup.api.deleteClientCharge(clientId, resourceId); + }); + + return { resourceId, clientId, chargeId, name, amount, dueDate }; +} diff --git a/playwright/factories/client.factory.spec.ts b/playwright/factories/client.factory.spec.ts index 8be56a6bf2..294fc046cc 100644 --- a/playwright/factories/client.factory.spec.ts +++ b/playwright/factories/client.factory.spec.ts @@ -7,7 +7,14 @@ */ import { test, expect } from '../fixtures/test-fixtures'; -import { createTestClient, DEFAULT_TEST_CLIENT_LASTNAME } from './client.factory'; +import { + createTestClient, + createActiveTestClient, + DEFAULT_TEST_CLIENT_LASTNAME, + DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE, + DEFAULT_TEST_CLIENT_ACTIVATION_DATE, + DEFAULT_ACCOUNT_OPENING_DATE +} from './client.factory'; import { E2E_NAME_PATTERN } from '../utils/naming'; // Live-backend specs — run under the `integration` Playwright project @@ -71,3 +78,120 @@ test.describe('createTestClient() against live Fineract', () => { await expect(apiSetup.api.getClient(client.resourceId)).rejects.toThrow(/404|not found/i); }); }); + +test.describe('createActiveTestClient() against live Fineract', () => { + test('creates a client already in Active status', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + + const fetched = await apiSetup.api.getClient(client.resourceId); + expect(fetched.id).toBe(client.resourceId); + expect(fetched.active).toBe(true); + expect(fetched.status?.value).toBe('Active'); + // The activation date must land one day after submission — this is + // the ordering every downstream account/charge factory relies on. + expect(fetched.timeline?.submittedOnDate).toEqual([ + 2024, + 1, + 1 + ]); + expect(fetched.timeline?.activatedOnDate).toEqual([ + 2024, + 1, + 2 + ]); + }); + + test('rejects an activation date that precedes submission', async ({ apiSetup, cleanupGuard }) => { + // Fails locally, before any HTTP request, so the error names the + // offending fields instead of surfacing as an opaque Fineract + // validation message. + await expect( + createActiveTestClient(apiSetup, cleanupGuard, { + submittedOnDate: '10 January 2024', + activationDate: '05 January 2024' + }) + ).rejects.toThrow(/activationDate .* must not precede submittedOnDate/); + expect(cleanupGuard.size()).toBe(0); + }); + + test('rejects an unparseable date literal', async ({ apiSetup, cleanupGuard }) => { + await expect(createActiveTestClient(apiSetup, cleanupGuard, { activationDate: 'not-a-date' })).rejects.toThrow( + /unable to parse dates/ + ); + expect(cleanupGuard.size()).toBe(0); + }); + + test('rejects a calendar-invalid date that Date.parse would have rolled over', async ({ apiSetup, cleanupGuard }) => { + // '31 February 2024' is not a real date. Date.parse tolerates some + // overflow forms; the strict parser must reject it locally rather + // than let it reach Fineract. + await expect( + createActiveTestClient(apiSetup, cleanupGuard, { activationDate: '31 February 2024' }) + ).rejects.toThrow(/unable to parse dates/); + expect(cleanupGuard.size()).toBe(0); + }); + + test('rejects a non-dd-MMMM-yyyy format Date.parse would accept', async ({ apiSetup, cleanupGuard }) => { + // ISO '2024-01-05' parses fine under Date.parse but is not the + // Fineract wire format, so the strict guard must reject it. + await expect(createActiveTestClient(apiSetup, cleanupGuard, { activationDate: '2024-01-05' })).rejects.toThrow( + /unable to parse dates/ + ); + expect(cleanupGuard.size()).toBe(0); + }); + + test('pins the wire date protocol even when extra supplies a conflicting dateFormat', async ({ + apiSetup, + cleanupGuard + }) => { + // The factory's dates are `dd MMMM yyyy` and `assertDateNotBefore` + // validates them as such. A caller who slips a different + // `dateFormat` into `extra` must not be able to desync the protocol + // from those values — otherwise Fineract tries to parse + // `02 January 2024` under `yyyy-MM-dd` and rejects the create. The + // create succeeding here proves `dateFormat` stayed pinned. + const client = await createActiveTestClient(apiSetup, cleanupGuard, { + extra: { dateFormat: 'yyyy-MM-dd' } + }); + + const fetched = await apiSetup.api.getClient(client.resourceId); + expect(fetched.active).toBe(true); + }); + + test('exposes an account-opening date after the activation date', () => { + // Guards the constant chain itself: a future edit that bumps the + // activation date past the account-opening date would silently + // break every Track B/C factory that defaults to it. + expect(Date.parse(DEFAULT_ACCOUNT_OPENING_DATE)).toBeGreaterThan(Date.parse(DEFAULT_TEST_CLIENT_ACTIVATION_DATE)); + expect(Date.parse(DEFAULT_TEST_CLIENT_ACTIVATION_DATE)).toBeGreaterThan( + Date.parse(DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE) + ); + }); + + test('extra cannot override the validated active-client invariants', async ({ apiSetup, cleanupGuard }) => { + // `createTestClient` spreads `extra` after its own defaults, so an + // earlier implementation let `extra` replace `submittedOnDate` — + // meaning the date-order guard validated one pair while Fineract + // received another. The invariants must win. + const client = await createActiveTestClient(apiSetup, cleanupGuard, { + extra: { + active: false, + activationDate: '31 December 2023', + submittedOnDate: '20 January 2024' + } + }); + + const fetched = await apiSetup.api.getClient(client.resourceId); + expect(fetched.active).toBe(true); + expect(fetched.timeline?.submittedOnDate).toEqual([ + 2024, + 1, + 1 + ]); + expect(fetched.timeline?.activatedOnDate).toEqual([ + 2024, + 1, + 2 + ]); + }); +}); diff --git a/playwright/factories/client.factory.ts b/playwright/factories/client.factory.ts index fae5ab38d8..58cbff826d 100644 --- a/playwright/factories/client.factory.ts +++ b/playwright/factories/client.factory.ts @@ -45,6 +45,28 @@ export const DEFAULT_TEST_CLIENT_LASTNAME = 'E2E'; /** Default submitted-on date applied to every pending test client. */ export const DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE = '01 January 2024'; +/** + * Default activation date for {@link createActiveTestClient}. + * + * Deliberately one day AFTER {@link DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE}: + * Fineract rejects an activation that predates submission, and the + * resulting error message names neither field, so an accidental + * inversion surfaces as an opaque validation failure. + */ +export const DEFAULT_TEST_CLIENT_ACTIVATION_DATE = '02 January 2024'; + +/** + * Earliest date a downstream account or charge may safely use. + * + * Loan applications, savings applications, and client charges are all + * rejected by Fineract when their own date precedes the owning + * client's activation date. Factories in Tracks B/C default their + * `submittedOnDate` to this constant so the whole chain + * (submitted -> activated -> account/charge) is monotonic by + * construction rather than by each spec author remembering the rule. + */ +export const DEFAULT_ACCOUNT_OPENING_DATE = '03 January 2024'; + /** Fineract `legalFormId` for an individual person. */ const LEGAL_FORM_PERSON = 1; @@ -135,3 +157,196 @@ export async function createTestClient( officeId }; } + +/** Caller-supplied tweaks to the default active-client payload. */ +export interface CreateActiveTestClientOverrides extends Omit { + /** + * Override the default activation date. MUST NOT precede + * `submittedOnDate` — Fineract rejects the create outright. + * + * ⚠️ Downstream monotonicity: the loan and savings factories default + * their account dates to {@link DEFAULT_ACCOUNT_OPENING_DATE}. An + * `activationDate` later than that constant produces a client whose + * activation postdates a default-dated account, and Fineract rejects + * that account. When you set a later activation, also pass matching + * `submittedOnDate` / `approvedOnDate` / `activatedOnDate` to those + * factories — the safe default (`02 January 2024`) already precedes + * `DEFAULT_ACCOUNT_OPENING_DATE`, so callers that take the defaults + * on both sides never hit this. + */ + activationDate?: string; + /** + * Extra payload fields merged BEFORE this factory's own invariants. + * + * `active`, `activationDate`, `submittedOnDate`, `dateFormat`, and + * `locale` are always applied last and cannot be overridden here. + * The first three are the values this factory validates; `dateFormat` + * and `locale` are the wire protocol those values are written in, and + * {@link assertDateNotBefore} assumes English `dd MMMM yyyy`. Letting + * `extra` replace any of them would mean validating one thing while + * sending another to Fineract. Use the dedicated `submittedOnDate` / + * `activationDate` options instead, which go through + * {@link assertDateNotBefore}. + */ + extra?: Record; +} + +/** + * Create an ACTIVE client owned by the current test and queue its + * deletion on the supplied {@link CleanupGuard}. + * + * Why this exists as a separate factory rather than an + * `{ active: true }` flag on {@link createTestClient}: every flow that + * opens a loan account, opens a savings account, or applies a client + * charge requires an active client, and each of those flows carries a + * date-ordering constraint relative to the activation date. Encoding + * the chain once here keeps ~20 downstream specs from re-deriving it. + * + * Cleanup caveat: Fineract only hard-deletes clients that are pending + * with no attached accounts. The deleter registered here will + * therefore FAIL for an active client, and the {@link CleanupGuard} + * will report it in `summary.failed` without throwing. That is + * deliberate and accepted — E2E databases are ephemeral, and the + * `E2E_` name prefix keeps orphans greppable. The registration is + * kept (rather than skipped) so that a test which happens to leave the + * client account-free still gets it removed. + * + * @param setup The per-test {@link ApiSetupManager}. + * @param guard The per-test {@link CleanupGuard}. + * @param overrides See {@link CreateActiveTestClientOverrides}. + * @returns A {@link TestClient} projection. No follow-up GET is + * issued; callers needing the activation timeline should + * call `setup.api.getClient(id)` themselves. + * @throws When `activationDate` precedes `submittedOnDate`, so the + * failure names the offending fields instead of surfacing as + * an opaque Fineract validation error. + */ +export async function createActiveTestClient( + setup: ApiSetupManager, + guard: CleanupGuard, + overrides: CreateActiveTestClientOverrides = {} +): Promise { + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE; + const activationDate = overrides.activationDate ?? DEFAULT_TEST_CLIENT_ACTIVATION_DATE; + + assertDateNotBefore(activationDate, submittedOnDate, 'createActiveTestClient', 'activationDate', 'submittedOnDate'); + + return createTestClient(setup, guard, { + firstname: overrides.firstname, + lastname: overrides.lastname, + officeId: overrides.officeId, + submittedOnDate, + extra: { + // Caller extras first, invariants last. `createTestClient` + // spreads this object after its own defaults, so anything left + // in here wins — including `submittedOnDate`. Ordering the + // spread this way keeps the validated fields authoritative: + // otherwise the guard above could pass on one date pair while + // Fineract received another. + ...overrides.extra, + active: true, + activationDate, + submittedOnDate, + // `dateFormat`/`locale` are the wire protocol the dates above are + // written in and that `assertDateNotBefore` assumes (`dd MMMM + // yyyy`, English). Pin them last so `extra` cannot desync the + // format from the values just validated — otherwise the guard + // checks one protocol while Fineract parses under another. + dateFormat: DEFAULT_DATE_FORMAT, + locale: DEFAULT_LOCALE + } + }); +} + +/** + * Guard that `later` is not chronologically before `earlier`. + * + * Both inputs must use Fineract's `dd MMMM yyyy` wire format (e.g. + * `'01 January 2024'`). Parsing goes through {@link parseFineractDate} + * rather than `Date.parse`, whose support for this format is + * implementation-defined and which would also accept unrelated + * date-time strings — either of which could let a malformed literal + * slip past this local check and fail only after the API request. + */ +function assertDateNotBefore( + later: string, + earlier: string, + caller: string, + laterLabel: string, + earlierLabel: string +): void { + const laterMs = parseFineractDate(later); + const earlierMs = parseFineractDate(earlier); + + if (laterMs === null || earlierMs === null) { + throw new Error( + `${caller}: unable to parse dates — ${laterLabel}='${later}', ${earlierLabel}='${earlier}'. ` + + `Expected Fineract's 'dd MMMM yyyy' format, e.g. '01 January 2024'.` + ); + } + + if (laterMs < earlierMs) { + throw new Error( + `${caller}: ${laterLabel} ('${later}') must not precede ${earlierLabel} ('${earlier}') — ` + + `Fineract rejects the create with a validation error that names neither field.` + ); + } +} + +/** + * The twelve English month names Fineract emits and accepts in its + * `dd MMMM yyyy` format, indexed 0-11 to match `Date.UTC`. + */ +const FINERACT_MONTHS = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december' +]; + +/** + * Strictly parse a Fineract `dd MMMM yyyy` date into epoch ms (UTC). + * + * Deliberately narrow: it accepts only `<1-2 digit day> <4-digit year>` and validates that the day is real for the + * given month/year (rejecting e.g. `'31 February 2024'`). Anything + * else returns `null`, so a malformed literal is caught here rather + * than by an opaque Fineract 400 after the request goes out. + * + * @param value - The candidate date string. + * @returns Epoch milliseconds, or `null` when `value` is not a valid + * `dd MMMM yyyy` date. + */ +function parseFineractDate(value: string): number | null { + const match = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(value.trim()); + if (!match) { + return null; + } + + const day = Number(match[1]); + const monthIndex = FINERACT_MONTHS.indexOf(match[2].toLowerCase()); + const year = Number(match[3]); + + if (monthIndex === -1) { + return null; + } + + const ms = Date.UTC(year, monthIndex, day); + // Reject overflow dates (e.g. day 31 in a 30-day month): Date.UTC + // would roll them into the next month, so a round-trip mismatch + // means the input was not a real calendar date. + const roundTrip = new Date(ms); + if (roundTrip.getUTCFullYear() !== year || roundTrip.getUTCMonth() !== monthIndex || roundTrip.getUTCDate() !== day) { + return null; + } + + return ms; +} diff --git a/playwright/factories/index.ts b/playwright/factories/index.ts index 8973dd022a..537d5461f5 100644 --- a/playwright/factories/index.ts +++ b/playwright/factories/index.ts @@ -17,13 +17,82 @@ * The barrel keeps spec files insulated from factory file moves and lets * the React counterpart export an identically-shaped module — making the * cross-framework portability swap a path-only change. + * + * Two flavours of factory live behind this barrel, and the distinction + * matters: + * + * - **API factories** (`client.factory.ts`) hit Fineract REST, return + * a real `resourceId`, and register their own teardown on the + * per-test `CleanupGuard`. Use these to arrange preconditions. + * - **Payload builders** (`client.ts`) are pure and never touch the + * network. They produce form-shaped data for driving a page object + * through the UI. + * + * Both modules previously exported a function called `createTestClient` + * with incompatible signatures, and only the pure builder was reachable + * through this barrel — so `import { createTestClient } from '../../factories'` + * and `import { createTestClient } from '../../factories/client.factory'` + * silently resolved to different functions. The pure builder is now + * re-exported as `buildTestClientPayload` to make the distinction + * explicit at the import site. */ +// ── API factories (network + cleanup registration) ───────────────── + export { createTestClient, + createActiveTestClient, + DEFAULT_TEST_CLIENT_LASTNAME, + DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE, + DEFAULT_TEST_CLIENT_ACTIVATION_DATE, + DEFAULT_ACCOUNT_OPENING_DATE, + type CreateTestClientOverrides, + type CreateActiveTestClientOverrides +} from './client.factory'; + +export { + createTestLoan, + createApprovedLoan, + createActiveLoan, + E2E_LOAN_PRODUCT_NAME, + type CreateTestLoanOverrides +} from './loan.factory'; + +export { + createTestSavingsAccount, + createApprovedSavingsAccount, + createActiveSavingsAccount, + E2E_SAVINGS_PRODUCT_NAME, + type CreateTestSavingsAccountOverrides +} from './savings.factory'; + +export { + createTestClientCharge, + ensureClientChargeDefinition, + E2E_CHARGE_NAMES, + CHARGE_TIME_TYPE, + DEFAULT_CHARGE_AMOUNT, + type TestClientCharge, + type CreateTestClientChargeOverrides +} from './charge.factory'; + +// `createTestGroup` produces a *pending*, memberless group precisely so +// its registered deleter can succeed. Tests that activate the group or +// attach members are opting into a teardown failure — expected, and +// recorded rather than thrown. +export { createTestGroup, DEFAULT_TEST_GROUP_SUBMITTED_ON_DATE, type CreateTestGroupOverrides } from './group.factory'; + +// ── Pure payload builders (no network) ───────────────────────────── + +export { + createTestClient as buildTestClientPayload, createSeededClient, type TestClientPayload, type ClientState, - type CreateTestClientOverrides, + type CreateTestClientOverrides as BuildTestClientPayloadOverrides, type SeededTestClient } from './client'; + +// ── Shared resolvers ─────────────────────────────────────────────── + +export { resolveDefaultOfficeId, resolveProductId, FIRST_OFFICE_CACHE_KEY } from './_shared'; diff --git a/playwright/factories/loan.factory.spec.ts b/playwright/factories/loan.factory.spec.ts new file mode 100644 index 0000000000..5564c66f2d --- /dev/null +++ b/playwright/factories/loan.factory.spec.ts @@ -0,0 +1,101 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '../fixtures/test-fixtures'; +import { createActiveTestClient } from './client.factory'; +import { createTestLoan, createApprovedLoan, createActiveLoan } from './loan.factory'; + +// Live-backend specs — `integration` Playwright project. No browser. +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('createTestLoan() against live Fineract', () => { + test('creates a loan in Submitted and pending approval', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const loan = await createTestLoan(apiSetup, cleanupGuard, client.resourceId); + + expect(typeof loan.resourceId).toBe('number'); + expect(loan.resourceId).toBeGreaterThan(0); + expect(loan.clientId).toBe(client.resourceId); + // Regression guard: `ensureMinimalLoanProduct` spells the key `id`, + // not `resourceId`. Reading the wrong one used to yield `undefined` + // and POST a loan with no product attached. + expect(loan.loanProductId).toBeGreaterThan(0); + + const fetched = await apiSetup.api.getLoan(loan.resourceId); + expect(fetched.id).toBe(loan.resourceId); + expect(fetched.status?.value).toBe('Submitted and pending approval'); + expect(fetched.loanProductId).toBe(loan.loanProductId); + }); + + test('registers deleters for both the loan and its client', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + await createTestLoan(apiSetup, cleanupGuard, client.resourceId); + + // Client first, loan second — the guard drains LIFO so the loan is + // deleted before its owning client, which is the only order + // Fineract's foreign keys accept. + expect(cleanupGuard.size()).toBe(2); + }); + + test('hard-deletes a pending loan on flush', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const loan = await createTestLoan(apiSetup, cleanupGuard, client.resourceId); + + const summary = await cleanupGuard.flush(); + // Scoped to the loan deleter on purpose. The owning client is + // ACTIVE, and Fineract only hard-deletes clients in Pending state, + // so that entry is an expected failure and asserting an empty + // `failed` array would be asserting the wrong thing. + expect(summary.failed.some((entry) => entry.label === `loan:${loan.resourceId}`)).toBe(false); + await expect(apiSetup.api.getLoan(loan.resourceId)).rejects.toThrow(/404|not found/i); + }); +}); + +test.describe('createApprovedLoan() against live Fineract', () => { + test('returns server state rather than the command envelope', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const loan = await createApprovedLoan(apiSetup, cleanupGuard, client.resourceId); + + // Fineract's approve response is a thin `{ loanId, changes }` + // envelope with no status/timeline/principal. These assertions only + // pass because the factory re-reads the loan afterwards. + expect(loan.status?.value).toBe('Approved'); + expect(loan.timeline?.approvedOnDate).toBeTruthy(); + expect(loan.principal).toBeGreaterThan(0); + }); +}); + +test.describe('createActiveLoan() against live Fineract', () => { + test('disburses the loan and reports Active status', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const loan = await createActiveLoan(apiSetup, cleanupGuard, client.resourceId); + + expect(loan.status?.value).toBe('Active'); + expect(loan.timeline?.actualDisbursementDate).toBeTruthy(); + + const fetched = await apiSetup.api.getLoan(loan.resourceId); + expect(fetched.status?.value).toBe('Active'); + // The disbursed amount must match the approved principal — the + // earlier projection bug silently disbursed 0 here. + expect(fetched.summary?.principalDisbursed).toBe(loan.principal); + }); + + test('records the expected-to-fail deleter without throwing', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const loan = await createActiveLoan(apiSetup, cleanupGuard, client.resourceId); + + // Fineract refuses to hard-delete a disbursed loan. The guard is + // expected to surface that as a recorded failure, not an exception + // that would mask the real test result. Scope the assertion to the + // loan's own deleter — the active client also fails to delete, so a + // bare `failed.length > 0` would still pass if the loan deleter were + // missing or had unexpectedly succeeded. + const summary = await cleanupGuard.flush(); + expect(summary.failed.some((entry) => entry.label === `loan:${loan.resourceId}`)).toBe(true); + }); +}); diff --git a/playwright/factories/loan.factory.ts b/playwright/factories/loan.factory.ts new file mode 100644 index 0000000000..4eb5986437 --- /dev/null +++ b/playwright/factories/loan.factory.ts @@ -0,0 +1,246 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Factory for a Fineract loan owned by the current Playwright test. + * + * Three state variants are exported, each building on the previous + * one so a spec only pays for the transitions it actually needs: + * + * - {@link createTestLoan} — "Submitted and pending approval". + * - {@link createApprovedLoan} — approved, not yet disbursed. + * - {@link createActiveLoan} — approved AND disbursed (Active). + * + * Every variant requires an ACTIVE client — Fineract rejects a loan + * application against a pending client — so callers are expected to + * have already produced one via `createActiveTestClient` (see + * `playwright/factories/client.factory.ts`). The client's activation + * date bounds this factory's default dates through + * `DEFAULT_ACCOUNT_OPENING_DATE`; passing an earlier `submittedOnDate` + * risks a validation error that names neither field. + * + * Cleanup caveat: Fineract only hard-deletes a loan while it is still + * "Submitted and pending approval". The deleter registered by + * {@link createApprovedLoan} and {@link createActiveLoan} will + * therefore FAIL, and the `CleanupGuard` records that without + * throwing — same accepted trade-off as `createActiveTestClient`. + * + * Portability note: this module imports only from the in-tree + * `ApiSetupManager` and `CleanupGuard` types. The React port can adopt + * it verbatim once it has its own `FineractApiClient` (or a + * structural equivalent). + */ + +import type { ApiSetupManager } from '../utils/api-setup-manager'; +import type { CleanupGuard } from '../utils/cleanup-guard'; +import type { TestLoan } from '../types/test-data.types'; +import { DEFAULT_ACCOUNT_OPENING_DATE } from './client.factory'; +import { resolveProductId } from './_shared'; + +/** + * Name of the shared minimal loan product seeded by + * `ApiSetupManager#ensureMinimalLoanProduct`. + * + * Exported so UI specs can pick the same product from the create-account + * dropdown that the API factories attach by id. Hard-coding the string in + * both places is how the two drift apart after a product rename. + */ +export const E2E_LOAN_PRODUCT_NAME = 'E2E Loan Product'; + +/** Caller-supplied tweaks accepted by every loan factory in this module. */ +export interface CreateTestLoanOverrides { + /** Fineract loan product id. Defaults to the shared minimal E2E product. */ + loanProductId?: number; + /** + * Submitted-on date. Defaults to {@link DEFAULT_ACCOUNT_OPENING_DATE} + * so a loan submitted with no overrides is always dated after the + * client's default activation date. + */ + submittedOnDate?: string; + /** + * Approval date. Defaults to `submittedOnDate`. + * + * Mirrors `CreateTestSavingsAccountOverrides` so a spec that + * arranges one loan and one savings account can pass the same date + * literals to both factories. + */ + approvedOnDate?: string; + /** Expected/actual disbursement date. Defaults to `submittedOnDate`. */ + disbursementDate?: string; +} + +/** + * Resolve (and cache) the shared minimal loan product id. + * + * A thin wrapper so every loan factory in this module shares the same + * `ApiSetupManager`-deduped product lookup instead of each calling + * `ensureMinimalLoanProduct` directly and depending on its own dedupe + * behaviour matching the others by coincidence. + */ +async function resolveLoanProductId(setup: ApiSetupManager, overrides: CreateTestLoanOverrides): Promise { + if (overrides.loanProductId !== undefined) { + return overrides.loanProductId; + } + return resolveProductId(await setup.ensureMinimalLoanProduct(), 'loan.factory'); +} + +/** + * Re-read a loan after a state transition and project it. + * + * Fineract's command responses (`approve`, `disburse`, ...) return only + * a thin `{ officeId, clientId, loanId, resourceId, changes }` + * envelope — no `status`, no `timeline`, no `principal`. Projecting + * straight off that envelope produced a `TestLoan` whose `principal` + * silently fell back to `0`, which then got passed as the disbursement + * amount. Re-fetching costs one GET and keeps the returned projection + * true to server state. + */ +async function refetchLoan( + setup: ApiSetupManager, + loanId: number, + clientId: number, + loanProductId: number +): Promise { + const loan = await setup.api.getLoan(loanId); + return { + resourceId: loanId, + displayName: `Loan #${loanId}`, + clientId, + loanProductId, + principal: loan.principal ?? loan.summary?.principalDisbursed ?? 0, + status: loan.status, + timeline: loan.timeline + }; +} + +/** + * Create a loan in "Submitted and pending approval" state and queue + * its deletion on the supplied {@link CleanupGuard}. + * + * Every numeric term field (principal, repayment schedule, interest + * rate, …) is derived from the loan product's template rather than + * hard-coded here — mirrors `createActiveLoanForClient` on + * `FineractApiClient`, which this factory delegates the create call to. + * + * @param setup The per-test {@link ApiSetupManager}. + * @param guard The per-test {@link CleanupGuard}. + * @param clientId - resourceId of an ACTIVE client (see module docstring). + * @param overrides See {@link CreateTestLoanOverrides}. + */ +export async function createTestLoan( + setup: ApiSetupManager, + guard: CleanupGuard, + clientId: number, + overrides: CreateTestLoanOverrides = {} +): Promise { + const loanProductId = await resolveLoanProductId(setup, overrides); + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_ACCOUNT_OPENING_DATE; + const template = await setup.api.getLoanTemplate(clientId, loanProductId); + const principal = template.principal ?? 1000; + + const response = await setup.api.createLoan({ + clientId, + productId: loanProductId, + submittedOnDate, + expectedDisbursementDate: overrides.disbursementDate ?? submittedOnDate, + principal, + loanType: 'individual', + loanTermFrequency: template.termFrequency ?? 1, + loanTermFrequencyType: template.termPeriodFrequencyType?.id ?? 2, + numberOfRepayments: template.numberOfRepayments ?? 1, + repaymentEvery: template.repaymentEvery ?? 1, + repaymentFrequencyType: template.repaymentFrequencyType?.id ?? 2, + interestRatePerPeriod: template.interestRatePerPeriod ?? 0, + interestRateFrequencyType: template.interestRateFrequencyType?.id ?? 2, + amortizationType: template.amortizationType?.id ?? 1, + interestType: template.interestType?.id ?? 0, + interestCalculationPeriodType: template.interestCalculationPeriodType?.id ?? 1, + transactionProcessingStrategyCode: template.transactionProcessingStrategyCode ?? 'mifos-standard-strategy', + dateFormat: 'dd MMMM yyyy', + locale: 'en' + }); + + const loanId: number = response.loanId ?? response.resourceId; + if (typeof loanId !== 'number') { + throw new Error( + `createTestLoan: Fineract create-loan response missing numeric loanId/resourceId, got ${JSON.stringify(response)}` + ); + } + + guard.register(`loan:${loanId}`, async () => { + await setup.api.deleteLoan(loanId); + }); + + return { + resourceId: loanId, + displayName: `Loan #${loanId}`, + clientId, + loanProductId, + principal + }; +} + +/** + * Create a loan and approve it. Builds on {@link createTestLoan} — the + * same deleter is reused, so cleanup still targets the one loan id. + */ +export async function createApprovedLoan( + setup: ApiSetupManager, + guard: CleanupGuard, + clientId: number, + overrides: CreateTestLoanOverrides = {} +): Promise { + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_ACCOUNT_OPENING_DATE; + const approvedOnDate = overrides.approvedOnDate ?? submittedOnDate; + + // Fineract rejects approval when the expected disbursement date + // falls before the approval date. `createTestLoan` defaults that + // date to `submittedOnDate`, so a caller who pushes `approvedOnDate` + // later — which specs do, to prove the date actually reached the + // server — would submit a loan that can never be approved. Carrying + // the disbursement date forward keeps the arrangement valid while + // still honouring an explicit `disbursementDate` override. + const disbursementDate = overrides.disbursementDate ?? approvedOnDate; + + const loan = await createTestLoan(setup, guard, clientId, { ...overrides, submittedOnDate, disbursementDate }); + await setup.api.approveLoan(loan.resourceId, approvedOnDate); + return refetchLoan(setup, loan.resourceId, clientId, loan.loanProductId); +} + +/** + * Create, approve, AND disburse a loan (Active state). + * + * Cleanup for this variant is expected to fail — Fineract will not + * hard-delete a disbursed loan. Registered anyway so a test that + * later undoes the disbursal still gets torn down. + */ +export async function createActiveLoan( + setup: ApiSetupManager, + guard: CleanupGuard, + clientId: number, + overrides: CreateTestLoanOverrides = {} +): Promise { + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_ACCOUNT_OPENING_DATE; + const approvedOnDate = overrides.approvedOnDate ?? submittedOnDate; + const disbursementDate = overrides.disbursementDate ?? approvedOnDate; + const loan = await createApprovedLoan(setup, guard, clientId, { ...overrides, submittedOnDate, approvedOnDate }); + + // `loan.principal` comes from the re-fetched loan, so it reflects the + // approved amount rather than a create-response fallback. Disbursing + // a zero amount is silently accepted by Fineract and leaves the loan + // in a state no assertion would expect. + if (!loan.principal) { + throw new Error( + `createActiveLoan: refusing to disburse loan ${loan.resourceId} with principal ${loan.principal} — ` + + `the loan template did not yield a usable principal.` + ); + } + + await setup.api.disburseLoan(loan.resourceId, disbursementDate, loan.principal); + return refetchLoan(setup, loan.resourceId, clientId, loan.loanProductId); +} diff --git a/playwright/factories/savings.factory.spec.ts b/playwright/factories/savings.factory.spec.ts new file mode 100644 index 0000000000..d00558da5b --- /dev/null +++ b/playwright/factories/savings.factory.spec.ts @@ -0,0 +1,85 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '../fixtures/test-fixtures'; +import { createActiveTestClient } from './client.factory'; +import { createTestSavingsAccount, createApprovedSavingsAccount, createActiveSavingsAccount } from './savings.factory'; + +// Live-backend specs — `integration` Playwright project. No browser. +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('createTestSavingsAccount() against live Fineract', () => { + test('creates an account in Submitted and pending approval', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const account = await createTestSavingsAccount(apiSetup, cleanupGuard, client.resourceId); + + expect(typeof account.resourceId).toBe('number'); + expect(account.resourceId).toBeGreaterThan(0); + expect(account.clientId).toBe(client.resourceId); + // Same regression guard as the loan factory: `ensureMinimalSavingsProduct` + // spells the key `id`, and reading `resourceId` yielded `undefined`. + expect(account.savingsProductId).toBeGreaterThan(0); + + const fetched = await apiSetup.api.getSavingsAccount(account.resourceId); + expect(fetched.id).toBe(account.resourceId); + expect(fetched.status?.value).toBe('Submitted and pending approval'); + expect(fetched.savingsProductId).toBe(account.savingsProductId); + }); + + test('hard-deletes a pending account on flush', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const account = await createTestSavingsAccount(apiSetup, cleanupGuard, client.resourceId); + expect(cleanupGuard.size()).toBe(2); + + const summary = await cleanupGuard.flush(); + // Scoped to the savings deleter on purpose. The owning client is + // ACTIVE, and Fineract only hard-deletes clients in Pending state, + // so that entry is an expected failure and asserting an empty + // `failed` array would be asserting the wrong thing. + expect(summary.failed.some((entry) => entry.label === `savings:${account.resourceId}`)).toBe(false); + await expect(apiSetup.api.getSavingsAccount(account.resourceId)).rejects.toThrow(/404|not found/i); + }); +}); + +test.describe('createApprovedSavingsAccount() against live Fineract', () => { + test('returns server state rather than the command envelope', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const account = await createApprovedSavingsAccount(apiSetup, cleanupGuard, client.resourceId); + + // Fineract's approve response carries no status/timeline; these + // only pass because the factory re-reads the account afterwards. + expect(account.status?.value).toBe('Approved'); + expect(account.timeline?.approvedOnDate).toBeTruthy(); + }); +}); + +test.describe('createActiveSavingsAccount() against live Fineract', () => { + test('activates the account and reports Active status', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const account = await createActiveSavingsAccount(apiSetup, cleanupGuard, client.resourceId); + + expect(account.status?.value).toBe('Active'); + expect(account.timeline?.activatedOnDate).toBeTruthy(); + + const fetched = await apiSetup.api.getSavingsAccount(account.resourceId); + expect(fetched.status?.value).toBe('Active'); + }); + + test('records the expected-to-fail deleter without throwing', async ({ apiSetup, cleanupGuard }) => { + const client = await createActiveTestClient(apiSetup, cleanupGuard); + const account = await createActiveSavingsAccount(apiSetup, cleanupGuard, client.resourceId); + + // Fineract will not hard-delete an activated savings account. The + // guard must record that as a failure, not throw and mask the test. + // Scope the assertion to the account's own deleter — the active + // client also fails to delete, so a bare `failed.length > 0` would + // still pass if this deleter were missing or had succeeded. + const summary = await cleanupGuard.flush(); + expect(summary.failed.some((entry) => entry.label === `savings:${account.resourceId}`)).toBe(true); + }); +}); diff --git a/playwright/factories/savings.factory.ts b/playwright/factories/savings.factory.ts new file mode 100644 index 0000000000..42be22cba9 --- /dev/null +++ b/playwright/factories/savings.factory.ts @@ -0,0 +1,183 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Factory for a Fineract savings account owned by the current + * Playwright test. + * + * Mirrors `loan.factory.ts` exactly — three state variants building on + * each other: + * + * - {@link createTestSavingsAccount} — "Submitted and pending approval". + * - {@link createApprovedSavingsAccount} — approved, not yet activated. + * - {@link createActiveSavingsAccount} — approved AND activated (Active). + * + * Requires an ACTIVE client for the same reason as the loan factory: + * Fineract rejects a savings application against a pending client. + * + * Cleanup caveat: identical to the loan factory. Fineract only + * hard-deletes a savings account while "Submitted and pending + * approval"; the deleter registered for the approved/active variants + * is expected to fail and is recorded by the `CleanupGuard` without + * throwing. + * + * Portability note: zero Angular/Playwright-page imports. The React + * port can adopt this file verbatim. + */ + +import type { ApiSetupManager } from '../utils/api-setup-manager'; +import type { CleanupGuard } from '../utils/cleanup-guard'; +import type { TestSavingsAccount } from '../types/test-data.types'; +import { DEFAULT_ACCOUNT_OPENING_DATE } from './client.factory'; +import { resolveProductId } from './_shared'; + +/** + * Name of the shared minimal savings product seeded by + * `ApiSetupManager#ensureMinimalSavingsProduct`. + * + * Exported so UI specs can pick the same product from the create-account + * dropdown that the API factories attach by id. Hard-coding the string in + * both places is how the two drift apart after a product rename. + */ +export const E2E_SAVINGS_PRODUCT_NAME = 'E2E Savings Product'; + +/** Caller-supplied tweaks accepted by every savings factory in this module. */ +export interface CreateTestSavingsAccountOverrides { + /** Fineract savings product id. Defaults to the shared minimal E2E product. */ + savingsProductId?: number; + /** + * Submitted-on date. Defaults to {@link DEFAULT_ACCOUNT_OPENING_DATE}, + * matching the loan factory's default so specs combining both + * account types don't need to reconcile two separate date constants. + */ + submittedOnDate?: string; + /** Approval date. Defaults to `submittedOnDate`. */ + approvedOnDate?: string; + /** Activation date. Defaults to `approvedOnDate`. */ + activatedOnDate?: string; +} + +/** + * Re-read a savings account after a state transition and project it. + * + * Fineract's `approve` / `activate` command responses carry only a thin + * `{ officeId, clientId, savingsId, resourceId, changes }` envelope — + * no `status` and no `timeline`. Projecting off that envelope produced + * a `TestSavingsAccount` whose `status` and `timeline` were always + * `undefined`, so any spec asserting on them would read a hole rather + * than server state. One extra GET keeps the projection honest. + */ +async function refetchSavingsAccount( + setup: ApiSetupManager, + savingsId: number, + clientId: number, + savingsProductId: number +): Promise { + const account = await setup.api.getSavingsAccount(savingsId); + return { + resourceId: savingsId, + displayName: `Savings #${savingsId}`, + clientId, + savingsProductId, + status: account.status, + timeline: account.timeline + }; +} + +/** + * Create a savings account in "Submitted and pending approval" state + * and queue its deletion on the supplied {@link CleanupGuard}. + * + * Delegates the actual create call to + * `FineractApiClient#createSavingsAccountForClient`, which derives the + * five required interest fields from the product template — see that + * method's docstring for why hand-writing them per call site is the + * most common source of create-account 400s. + * + * @param setup The per-test {@link ApiSetupManager}. + * @param guard The per-test {@link CleanupGuard}. + * @param clientId - resourceId of an ACTIVE client (see module docstring). + * @param overrides See {@link CreateTestSavingsAccountOverrides}. + */ +export async function createTestSavingsAccount( + setup: ApiSetupManager, + guard: CleanupGuard, + clientId: number, + overrides: CreateTestSavingsAccountOverrides = {} +): Promise { + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_ACCOUNT_OPENING_DATE; + + let savingsProductId = overrides.savingsProductId; + if (savingsProductId === undefined) { + savingsProductId = resolveProductId(await setup.ensureMinimalSavingsProduct(), 'savings.factory'); + } + + const response = await setup.api.createSavingsAccountForClient(clientId, submittedOnDate, savingsProductId); + const savingsId: number = response.savingsId ?? response.resourceId; + if (typeof savingsId !== 'number') { + throw new Error( + `createTestSavingsAccount: Fineract create-savings-account response missing numeric savingsId/resourceId, got ${JSON.stringify( + response + )}` + ); + } + + guard.register(`savings:${savingsId}`, async () => { + await setup.api.deleteSavingsAccount(savingsId); + }); + + return { + resourceId: savingsId, + displayName: `Savings #${savingsId}`, + clientId, + savingsProductId + }; +} + +/** + * Create a savings account and approve it. Builds on + * {@link createTestSavingsAccount} — the same deleter is reused. + */ +export async function createApprovedSavingsAccount( + setup: ApiSetupManager, + guard: CleanupGuard, + clientId: number, + overrides: CreateTestSavingsAccountOverrides = {} +): Promise { + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_ACCOUNT_OPENING_DATE; + const approvedOnDate = overrides.approvedOnDate ?? submittedOnDate; + + const account = await createTestSavingsAccount(setup, guard, clientId, { ...overrides, submittedOnDate }); + await setup.api.approveSavingsAccount(account.resourceId, approvedOnDate); + return refetchSavingsAccount(setup, account.resourceId, clientId, account.savingsProductId); +} + +/** + * Create, approve, AND activate a savings account (Active state). + * + * Cleanup for this variant is expected to fail — Fineract will not + * hard-delete an activated savings account. + */ +export async function createActiveSavingsAccount( + setup: ApiSetupManager, + guard: CleanupGuard, + clientId: number, + overrides: CreateTestSavingsAccountOverrides = {} +): Promise { + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_ACCOUNT_OPENING_DATE; + const approvedOnDate = overrides.approvedOnDate ?? submittedOnDate; + const activatedOnDate = overrides.activatedOnDate ?? approvedOnDate; + + const account = await createApprovedSavingsAccount(setup, guard, clientId, { + ...overrides, + submittedOnDate, + approvedOnDate + }); + await setup.api.activateSavingsAccount(account.resourceId, activatedOnDate); + return refetchSavingsAccount(setup, account.resourceId, clientId, account.savingsProductId); +} diff --git a/playwright/fixtures/fineract-api.spec.ts b/playwright/fixtures/fineract-api.spec.ts new file mode 100644 index 0000000000..f8538e7725 --- /dev/null +++ b/playwright/fixtures/fineract-api.spec.ts @@ -0,0 +1,72 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '@playwright/test'; + +import { FineractApiClient } from './fineract-api'; + +/** + * Pure-logic unit spec for {@link FineractApiClient} URL construction — + * no live backend, no browser. The request context is replaced with a + * recorder so the assertion is on the URL the client *would* send. + * + * Regression target: `productId: 0` is a valid product scope. A + * truthiness guard (`if (productId)`) drops it, silently downgrading the + * template request to an unscoped one. These tests pin the explicit + * `!== undefined` guard in `getLoanTemplate` / `getSavingsAccountTemplate`. + */ + +/** + * Build a client whose request context records the URL of the next GET + * and returns a minimal OK/JSON response, so template methods resolve + * without touching the network. + * + * @param record - Receives the URL passed to `ctx.get`. + * @returns A `FineractApiClient` with its private context stubbed. + */ +function clientRecordingGetUrl(record: (url: string) => void): FineractApiClient { + const client = new FineractApiClient('http://localhost', 'default', 'user', 'pass'); + // The request context is a private field created in `init()`; the unit + // under test is URL construction, so a recording stub stands in for it. + (client as unknown as { ctx: unknown }).ctx = { + get: async (url: string) => { + record(url); + return { ok: () => true, json: async () => ({}) }; + } + }; + return client; +} + +test.describe('FineractApiClient · template product scoping', () => { + test('getSavingsAccountTemplate sends productId=0 rather than dropping it', async () => { + let requestedUrl = ''; + const client = clientRecordingGetUrl((url) => (requestedUrl = url)); + + await client.getSavingsAccountTemplate(1, 0); + + expect(requestedUrl).toContain('productId=0'); + }); + + test('getLoanTemplate sends productId=0 rather than dropping it', async () => { + let requestedUrl = ''; + const client = clientRecordingGetUrl((url) => (requestedUrl = url)); + + await client.getLoanTemplate(1, 0); + + expect(requestedUrl).toContain('productId=0'); + }); + + test('getSavingsAccountTemplate omits productId when it is undefined', async () => { + let requestedUrl = ''; + const client = clientRecordingGetUrl((url) => (requestedUrl = url)); + + await client.getSavingsAccountTemplate(1); + + expect(requestedUrl).not.toContain('productId'); + }); +}); diff --git a/playwright/fixtures/fineract-api.ts b/playwright/fixtures/fineract-api.ts index 3155b8ef24..a09a94ae3a 100644 --- a/playwright/fixtures/fineract-api.ts +++ b/playwright/fixtures/fineract-api.ts @@ -14,6 +14,24 @@ export class FineractApiClient { private static readonly CLIENT_WITHDRAW_REASON_CODE_NAME = 'ClientWithdrawReason'; private static readonly DEFAULT_DATE_FORMAT = 'dd MMMM yyyy'; private static readonly DEFAULT_LOCALE = 'en'; + private static readonly DEFAULT_CURRENCY_CODE = 'USD'; + + // Savings enum ids. Fineract exposes these through + // `/savingsproducts/template`; they are stable across stock tenants, + // and naming them here keeps the seed payload readable instead of a + // wall of bare integers. Tests asserting on a specific option should + // still read it from the template rather than these constants. + /** `interestCompoundingPeriodType` — Daily. */ + private static readonly SAVINGS_COMPOUNDING_DAILY = 1; + /** `interestPostingPeriodType` — Monthly. */ + private static readonly SAVINGS_POSTING_MONTHLY = 4; + /** `interestCalculationType` — Daily balance. */ + private static readonly SAVINGS_CALCULATION_DAILY_BALANCE = 1; + /** `interestCalculationDaysInYearType` — 365 days. */ + private static readonly SAVINGS_DAYS_IN_YEAR_365 = 365; + /** Nominal annual interest rate applied to the shared E2E product. */ + private static readonly DEFAULT_SAVINGS_INTEREST_RATE = 5; + private static readonly CREATE_RACE_RETRY_DELAY_MS = 250; private static readonly CREATE_RACE_RETRY_COUNT = 2; private ctx!: APIRequestContext; @@ -134,19 +152,6 @@ export class FineractApiClient { return this.validateResponse(res, 'getClientTemplate'); } - /** - * Deletes a client by id. Fineract only allows hard-delete for clients - * that are still in pending state with no associated accounts — the - * factories in `playwright/factories/client.factory.ts` always create - * pending clients precisely so the cleanup-guard teardown can succeed. - * @param clientId - The client id to delete - * @returns The Fineract delete-client response payload - */ - async deleteClient(clientId: number): Promise { - const res = await this.ctx.delete(`/fineract-provider/api/v1/clients/${clientId}`); - return this.validateResponse(res, 'deleteClient'); - } - /** * Creates a group using the supplied request payload. * @param data - The group creation payload @@ -178,6 +183,70 @@ export class FineractApiClient { return this.validateResponse(res, 'deleteGroup'); } + /** + * Executes a lifecycle or membership command against a group. + * + * Fineract routes every group mutation that is not a plain field + * update through `POST /groups/{id}?command=...`. Known commands + * include `activate`, `close`, `associateClients`, + * `disassociateClients`, `assignStaff` and `unassignStaff`. + * + * @param groupId - The group id to act on + * @param command - The Fineract command name + * @param data - The command payload (may be empty for some commands) + * @returns The Fineract command response payload + */ + async executeGroupCommand(groupId: number, command: string, data: Record = {}): Promise { + const res = await this.ctx.post( + `/fineract-provider/api/v1/groups/${groupId}?command=${encodeURIComponent(command)}`, + { data } + ); + return this.validateResponse(res, `executeGroupCommand:${command}`); + } + + /** + * Activates a pending group. + * + * Fineract rejects an activation date earlier than the group's + * submitted-on date, so the default here matches + * `DEFAULT_TEST_GROUP_SUBMITTED_ON_DATE` in the group factory. + * + * Note an activated group can no longer be hard-deleted, so any + * `CleanupGuard` entry registered by the factory will fail on + * teardown. That failure is recorded, not thrown — see + * `CleanupGuard`. + * + * @param groupId - The group id to activate + * @param activationDate - Activation date in `dd MMMM yyyy` form + * @returns The Fineract activate-group response payload + */ + async activateGroup(groupId: number, activationDate = '01 January 2024'): Promise { + return this.executeGroupCommand(groupId, 'activate', { + activationDate, + dateFormat: 'dd MMMM yyyy', + locale: 'en' + }); + } + + /** + * Fetches the client members currently associated with a group. + * + * `GET /groups/{id}` omits `clientMembers` unless the association is + * explicitly requested, so this always asks for it and normalises + * the missing case to an empty array — a memberless group and a + * group whose members were not requested are indistinguishable + * otherwise, and quietly returning `undefined` would make every + * caller repeat the same guard. + * + * @param groupId - The group id whose members to fetch + * @returns The member client collection, empty when there are none + */ + async getGroupClientMembers(groupId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/groups/${groupId}?associations=clientMembers`); + const group = await this.validateResponse(res, 'getGroupClientMembers'); + return Array.isArray(group?.clientMembers) ? group.clientMembers : []; + } + /** * Creates a user (application user / staff with login) using the supplied payload. * @param data - The user creation payload @@ -260,7 +329,10 @@ export class FineractApiClient { staffInSelectedOfficeOnly: 'true' }); - if (productId) { + // Explicit undefined check, not a truthiness test: a product id of 0 + // is a valid scope and must still be sent, or the template silently + // falls back to an unscoped (product-agnostic) response. + if (productId !== undefined) { query.set('productId', productId.toString()); } @@ -278,6 +350,20 @@ export class FineractApiClient { return this.validateResponse(res, 'createLoan'); } + /** + * Deletes a loan by id. + * + * Fineract only permits hard-delete while the loan is still in + * "Submitted and pending approval". Approved or disbursed loans + * cannot be deleted — the `CleanupGuard` records the failure without + * throwing. + * @param loanId - The loan id to delete + */ + async deleteLoan(loanId: number): Promise { + const res = await this.ctx.delete(`/fineract-provider/api/v1/loans/${loanId}`); + await this.validateResponse(res, 'deleteLoan'); + } + /** * Fetches code values for a specific code id. * @param codeId - The code id whose values should be fetched @@ -311,6 +397,32 @@ export class FineractApiClient { return this.validateResponse(res, 'executeClientCommand'); } + /** + * Attempts a client command WITHOUT throwing on non-2xx responses. + * + * Sibling of {@link executeClientCommand} intended for negative-path + * tests (illegal state transitions, validation-failure matrices) + * where the error body IS the assertion target. Returns the raw + * status code and body text so callers can pattern-match against + * Fineract's `userMessageGlobalisationCode`, `developerMessage`, + * or free-form user message strings — the same payload the UI + * snackbar renders. + * + * @param clientId - The client id to operate on + * @param command - The Fineract client command name (e.g. 'activate') + * @param data - The command payload + * @returns Response envelope with ok flag, HTTP status, and raw body text + */ + async tryExecuteClientCommand( + clientId: number, + command: string, + data: Record + ): Promise<{ ok: boolean; status: number; bodyText: string }> { + const res = await this.ctx.post(`/fineract-provider/api/v1/clients/${clientId}?command=${command}`, { data }); + const bodyText = await res.text(); + return { ok: res.ok(), status: res.status(), bodyText }; + } + /** * Executes a command against an existing loan resource. * @param loanId - The loan id to operate on @@ -700,12 +812,175 @@ export class FineractApiClient { return officeId; } + // ── Savings products ───────────────────────────────────────────── + + /** + * Fetches all configured savings products. + * + * Used by {@link ensureMinimalSavingsProduct} to decide whether the + * shared E2E product already exists. Unlike loan products, Fineract + * exposes no `basic-details` projection for savings, so this returns + * the full product list. + * @returns The savings product collection + */ + async getSavingsProducts(): Promise { + const res = await this.ctx.get('/fineract-provider/api/v1/savingsproducts'); + return this.validateResponse(res, 'getSavingsProducts'); + } + + /** + * Fetches the savings product creation template. + * + * The template carries the enum option lists + * (`interestCompoundingPeriodTypeOptions`, + * `interestPostingPeriodTypeOptions`, …) that the create-product + * payload references by id. Callers that need to assert against a + * specific option should read it from here rather than hard-coding + * an id, since the ids are tenant-configurable. + * @returns The savings product template payload + */ + async getSavingsProductTemplate(): Promise { + const res = await this.ctx.get('/fineract-provider/api/v1/savingsproducts/template'); + return this.validateResponse(res, 'getSavingsProductTemplate'); + } + + /** + * Creates a savings product using the supplied request payload. + * @param data - The savings product creation payload + * @returns The Fineract create-savings-product response payload + */ + async createSavingsProduct(data: Record): Promise { + const res = await this.ctx.post('/fineract-provider/api/v1/savingsproducts', { data }); + return this.validateResponse(res, 'createSavingsProduct'); + } + + /** + * Ensures a minimal savings product exists for savings-account tests. + * + * Mirrors {@link ensureMinimalLoanProduct} exactly, including the + * duplicate-create race retry: several workers may reach this method + * concurrently on a cold tenant, and only one POST can win. + * + * The product name is deliberately FIXED (not `E2E_`-suffixed via + * `generateE2EName`). Products are shared infrastructure rather than + * per-test data — generating a unique product per test would bloat + * the tenant and defeat the `ApiSetupManager` dedupe that makes this + * a one-time cost for the whole run. + * + * `accountingRule: 1` (NONE) keeps the product free of any + * chart-of-accounts / GL mapping prerequisites. + * + * @param options - Optional savings product identifiers to match or create + * @returns The existing or created savings product + */ + async ensureMinimalSavingsProduct( + options: { + name?: string; + shortName?: string; + } = {} + ): Promise { + const name = options.name ?? 'E2E Savings Product'; + const shortName = options.shortName ?? 'E2SP'; + + for (let attempt = 0; attempt <= FineractApiClient.CREATE_RACE_RETRY_COUNT; attempt += 1) { + const existingProducts = await this.getSavingsProducts(); + const existingProduct = existingProducts.find( + (product) => product.name === name && product.shortName === shortName + ); + + if (existingProduct) { + return existingProduct; + } + + try { + const createdProduct = await this.createSavingsProduct({ + name, + shortName, + description: 'Seeded for Playwright tests', + currencyCode: FineractApiClient.DEFAULT_CURRENCY_CODE, + digitsAfterDecimal: 2, + inMultiplesOf: 0, + // These five interest fields map 1:1 onto the required + // controls of the savings-account TERMS step, so an account + // created from this product can be submitted without the + // spec supplying any of them explicitly. + nominalAnnualInterestRate: FineractApiClient.DEFAULT_SAVINGS_INTEREST_RATE, + interestCompoundingPeriodType: FineractApiClient.SAVINGS_COMPOUNDING_DAILY, + interestPostingPeriodType: FineractApiClient.SAVINGS_POSTING_MONTHLY, + interestCalculationType: FineractApiClient.SAVINGS_CALCULATION_DAILY_BALANCE, + interestCalculationDaysInYearType: FineractApiClient.SAVINGS_DAYS_IN_YEAR_365, + accountingRule: 1, + withdrawalFeeForTransfers: false, + // NOTE: no `dateFormat` here, deliberately. Unlike almost + // every other Fineract create endpoint, /savingsproducts + // carries no date field at all, and its parameter allow-list + // rejects `dateFormat` outright with + // "The parameter dateFormat is not supported." `locale` is + // still required — it drives decimal parsing on the interest + // rate. + locale: FineractApiClient.DEFAULT_LOCALE, + charges: [] + }); + + return { + id: createdProduct.resourceId, + name, + shortName + }; + } catch (error) { + if (!this.isRecoverableDuplicateError(error)) { + throw error; + } + + if (attempt === FineractApiClient.CREATE_RACE_RETRY_COUNT) { + const refreshedProducts = await this.getSavingsProducts(); + const refreshedProduct = refreshedProducts.find( + (product) => product.name === name && product.shortName === shortName + ); + + if (refreshedProduct) { + return refreshedProduct; + } + } else { + await this.waitForCreateRaceRetry(); + } + } + } + + throw new Error(`Fineract API error [ensureMinimalSavingsProduct]: failed to resolve savings product '${name}'`); + } + + // ── Savings accounts ───────────────────────────────────────────── + + /** + * Fetches the savings-account application template for a client. + * @param clientId - The client id used to resolve the template + * @param productId - Optional savings product id to scope the template + * @returns The savings account template payload + */ + async getSavingsAccountTemplate(clientId: number, productId?: number): Promise { + const query = new URLSearchParams({ clientId: clientId.toString() }); + + // Explicit undefined check, not a truthiness test: a product id of 0 + // is a valid scope and must still be sent, or the template silently + // falls back to an unscoped (product-agnostic) response. + if (productId !== undefined) { + query.set('productId', productId.toString()); + } + + const res = await this.ctx.get(`/fineract-provider/api/v1/savingsaccounts/template?${query.toString()}`); + return this.validateResponse(res, 'getSavingsAccountTemplate'); + } + /** * Creates a savings account. - * @param overrides Must include mandatory Fineract fields: - * submittedOnDate, dateFormat, locale, nominalAnnualInterestRate, - * interestCompoundingPeriodType, interestPostingPeriodType, - * interestCalculationType, interestCalculationDaysInYearType + * + * Fineract requires the five interest fields plus `submittedOnDate`, + * `dateFormat`, and `locale` on every create. Prefer + * {@link createSavingsAccountForClient}, which derives them from the + * product template, over calling this directly with hand-written + * values. + * * @param clientId - The client id that owns the savings account * @param productId - The savings product id to create the account from * @param overrides - Additional savings account creation fields @@ -722,6 +997,198 @@ export class FineractApiClient { return this.validateResponse(res, 'createSavingsAccount'); } + /** + * Fetches a savings account with all associations by id. + * @param savingsAccountId - The savings account id to fetch + * @returns The requested savings account payload + */ + async getSavingsAccount(savingsAccountId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/savingsaccounts/${savingsAccountId}?associations=all`); + return this.validateResponse(res, 'getSavingsAccount'); + } + + /** + * Deletes a savings account by id. + * + * Fineract only permits hard-delete while the account is still in + * "Submitted and pending approval". Approved or active accounts + * cannot be deleted at all — the `CleanupGuard` records the failure + * without throwing, and the `E2E_` name prefix keeps the orphan + * greppable. + * @param savingsAccountId - The savings account id to delete + */ + async deleteSavingsAccount(savingsAccountId: number): Promise { + const res = await this.ctx.delete(`/fineract-provider/api/v1/savingsaccounts/${savingsAccountId}`); + await this.validateResponse(res, 'deleteSavingsAccount'); + } + + /** + * Executes a command against an existing savings account resource. + * @param savingsAccountId - The savings account id to operate on + * @param command - The Fineract savings command name (approve, activate, …) + * @param data - The command payload + * @returns The command response payload + */ + async executeSavingsCommand( + savingsAccountId: number, + command: string, + data: Record = {} + ): Promise { + const res = await this.ctx.post( + `/fineract-provider/api/v1/savingsaccounts/${savingsAccountId}?command=${command}`, + { data } + ); + return this.validateResponse(res, 'executeSavingsCommand'); + } + + /** + * Attempts a savings command WITHOUT throwing on non-2xx responses. + * + * Sibling of {@link executeSavingsCommand} for negative-path tests + * (illegal transitions, validation matrices) where the error body IS + * the assertion target. Mirrors {@link tryExecuteClientCommand}. + * + * @param savingsAccountId - The savings account id to operate on + * @param command - The Fineract savings command name + * @param data - The command payload + * @returns Response envelope with ok flag, HTTP status, and raw body text + */ + async tryExecuteSavingsCommand( + savingsAccountId: number, + command: string, + data: Record = {} + ): Promise<{ ok: boolean; status: number; bodyText: string }> { + const res = await this.ctx.post( + `/fineract-provider/api/v1/savingsaccounts/${savingsAccountId}?command=${command}`, + { data } + ); + const bodyText = await res.text(); + return { ok: res.ok(), status: res.status(), bodyText }; + } + + /** + * Approves a savings account on the provided date. + * @param savingsAccountId - The savings account id to approve + * @param approvedOnDate - The approval date in Fineract's expected format + * @returns The approve command response payload + */ + async approveSavingsAccount(savingsAccountId: number, approvedOnDate: string): Promise { + return this.executeSavingsCommand(savingsAccountId, 'approve', { + approvedOnDate, + dateFormat: FineractApiClient.DEFAULT_DATE_FORMAT, + locale: FineractApiClient.DEFAULT_LOCALE + }); + } + + /** + * Activates an approved savings account on the provided date. + * @param savingsAccountId - The savings account id to activate + * @param activatedOnDate - The activation date in Fineract's expected format + * @returns The activate command response payload + */ + async activateSavingsAccount(savingsAccountId: number, activatedOnDate: string): Promise { + return this.executeSavingsCommand(savingsAccountId, 'activate', { + activatedOnDate, + dateFormat: FineractApiClient.DEFAULT_DATE_FORMAT, + locale: FineractApiClient.DEFAULT_LOCALE + }); + } + + /** + * Returns the tenant's configured payment types. + */ + async getPaymentTypes(): Promise { + const res = await this.ctx.get('/fineract-provider/api/v1/paymenttypes'); + return this.validateResponse(res, 'getPaymentTypes'); + } + + /** + * Posts a transaction against an ACTIVE savings account. + * + * `paymentTypeId` is mandatory on this endpoint — Fineract rejects + * the request with + * "The parameter `paymentTypeId` is mandatory." otherwise — but it is + * a tenant-configured lookup, so there is no id that can safely be + * hard-coded. When the caller does not supply one, the first + * configured payment type is resolved and used, matching what the UI + * does when a user leaves the dropdown on its default. + * + * @param savingsAccountId - The savings account id to transact on + * @param command - Either 'deposit' or 'withdrawal' + * @param transactionDate - The transaction date in Fineract's expected format + * @param transactionAmount - The transaction amount + * @param paymentTypeId - Optional payment type; defaults to the first configured one + * @returns The transaction response payload + */ + async createSavingsTransaction( + savingsAccountId: number, + command: 'deposit' | 'withdrawal', + transactionDate: string, + transactionAmount: number, + paymentTypeId?: number + ): Promise { + let resolvedPaymentTypeId = paymentTypeId; + if (resolvedPaymentTypeId === undefined) { + const paymentTypes = await this.getPaymentTypes(); + if (!paymentTypes.length) { + throw new Error( + 'createSavingsTransaction: the tenant has no payment types configured, so no transaction can be posted. ' + + 'Seed at least one payment type before running savings transaction tests.' + ); + } + resolvedPaymentTypeId = paymentTypes[0].id; + } + + const res = await this.ctx.post( + `/fineract-provider/api/v1/savingsaccounts/${savingsAccountId}/transactions?command=${command}`, + { + data: { + transactionDate, + transactionAmount, + paymentTypeId: resolvedPaymentTypeId, + dateFormat: FineractApiClient.DEFAULT_DATE_FORMAT, + locale: FineractApiClient.DEFAULT_LOCALE + } + } + ); + return this.validateResponse(res, 'createSavingsTransaction'); + } + + /** + * Creates a savings account for a client, deriving every required + * interest field from the product template. + * + * This is the method factories should call: hand-writing the five + * interest fields at each call site is the most common source of + * `createSavingsAccount` 400s, and the template already carries + * tenant-correct values. + * + * @param clientId - The client id that owns the account + * @param submittedOnDate - The submitted-on date. MUST NOT precede + * the client's activation date or Fineract rejects the create. + * @param productId - Optional savings product id; defaults to the + * shared minimal E2E product. + * @returns The Fineract create-savings-account response payload + */ + async createSavingsAccountForClient(clientId: number, submittedOnDate: string, productId?: number): Promise { + const resolvedProductId = productId ?? (await this.ensureMinimalSavingsProduct()).id; + const template = await this.getSavingsAccountTemplate(clientId, resolvedProductId); + + return this.createSavingsAccount(clientId, resolvedProductId, { + submittedOnDate, + nominalAnnualInterestRate: template.nominalAnnualInterestRate ?? FineractApiClient.DEFAULT_SAVINGS_INTEREST_RATE, + interestCompoundingPeriodType: + template.interestCompoundingPeriodType?.id ?? FineractApiClient.SAVINGS_COMPOUNDING_DAILY, + interestPostingPeriodType: template.interestPostingPeriodType?.id ?? FineractApiClient.SAVINGS_POSTING_MONTHLY, + interestCalculationType: + template.interestCalculationType?.id ?? FineractApiClient.SAVINGS_CALCULATION_DAILY_BALANCE, + interestCalculationDaysInYearType: + template.interestCalculationDaysInYearType?.id ?? FineractApiClient.SAVINGS_DAYS_IN_YEAR_365, + dateFormat: FineractApiClient.DEFAULT_DATE_FORMAT, + locale: FineractApiClient.DEFAULT_LOCALE + }); + } + /** * Deletes a client by id. Only works for clients in Pending state * (Fineract rejects deletion of active/closed clients). @@ -753,6 +1220,263 @@ export class FineractApiClient { await this.validateResponse(res, 'deleteClientFamilyMember'); } + // ── Charge definitions ───────────────────────────────────────────── + // + // A "charge" in Fineract is a reusable *definition* (name, currency, + // amount, when it applies), quite separate from a "client charge", + // which is one definition attached to one client. The Add Charge + // form's dropdown is populated from definitions filtered to + // `chargeAppliesTo: 3` (Client) — with none configured the dropdown + // renders empty and the rest of the form never appears, so seeding a + // definition is a hard prerequisite for any charge E2E test. + + /** + * Fetches all charge definitions. + * @returns The charge definition collection + */ + async getCharges(): Promise { + const res = await this.ctx.get('/fineract-provider/api/v1/charges'); + const body = await this.validateResponse(res, 'getCharges'); + return Array.isArray(body) ? body : []; + } + + /** + * Creates a charge definition. + * @param data - The charge definition payload + * @returns The Fineract create-charge response payload + */ + async createCharge(data: Record): Promise { + const res = await this.ctx.post('/fineract-provider/api/v1/charges', { data }); + return this.validateResponse(res, 'createCharge'); + } + + /** + * Finds or creates a flat client-applicable charge definition. + * + * ── Why this is an ensure, not a create ───────────────────────── + * + * Charge definitions are global and cannot be deleted once any + * client charge references them, so creating one per test would leak + * a growing pile of definitions into the tenant and eventually make + * the Add Charge dropdown unusable by hand. Reusing a fixed name + * keeps exactly one definition per time type, which is also what + * lets specs assert on the option label. + * + * `chargeTimeType` is the parameter that actually changes the *form* + * under test: Specified due date (2) renders a `dueDate` control, + * while Annual (6) and Monthly (7) swap in `feeOnMonthDay` — hence + * separate definitions rather than one shared default. + * + * @param name - Definition name; must be unique per time type + * @param chargeTimeType - Fineract charge time type id (2/6/7) + * @param amount - Flat charge amount + * @returns The charge definition, whether found or freshly created + */ + async ensureClientChargeDefinition(name = 'E2E Client Charge', chargeTimeType = 2, amount = 100): Promise { + const existing = (await this.getCharges()).find((charge) => charge?.name === name); + if (existing) { + return existing; + } + + const payload = { + name, + // 3 = Client. Anything else and the definition never reaches the + // Add Charge dropdown. + chargeAppliesTo: 3, + chargeTimeType, + // 1 = Flat. Percentage types would need a base amount that a + // client charge has no notion of. + chargeCalculationType: 1, + currencyCode: 'USD', + amount, + active: true, + penalty: false, + locale: 'en', + monthDayFormat: 'dd MMMM' + }; + + try { + const created = await this.createCharge(payload); + const charges = await this.getCharges(); + return charges.find((charge) => charge?.id === created?.resourceId) ?? created; + } catch (error) { + // Two workers can race to seed the same definition. A duplicate + // means the other worker won, so re-read rather than fail. + if (!this.isRecoverableDuplicateError(error)) { + throw error; + } + await this.waitForCreateRaceRetry(); + const charges = await this.getCharges(); + const found = charges.find((charge) => charge?.name === name); + if (!found) { + throw error; + } + return found; + } + } + + // ── Client charges ──────────────────────────────────────────────── + + /** + * Returns the charges attached to a client. + * @param clientId - The owning client id + * @returns The client charge collection + */ + async getClientCharges(clientId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/clients/${clientId}/charges`); + const body = await this.validateResponse(res, 'getClientCharges'); + return body?.pageItems ?? (Array.isArray(body) ? body : []); + } + + /** + * Fetches a single client charge. + * @param clientId - The owning client id + * @param clientChargeId - The client charge id + * @returns The client charge payload + */ + async getClientCharge(clientId: number, clientChargeId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/clients/${clientId}/charges/${clientChargeId}`); + return this.validateResponse(res, 'getClientCharge'); + } + + /** + * Attaches a charge definition to a client. + * @param clientId - The owning client id + * @param chargeId - The charge definition id + * @param dueDate - Due date in `dd MMMM yyyy` wire format + * @param amount - Optional amount override + * @returns The Fineract create-client-charge response payload + */ + async createClientCharge(clientId: number, chargeId: number, dueDate: string, amount?: number): Promise { + const data: Record = { + chargeId, + dueDate, + locale: 'en', + dateFormat: 'dd MMMM yyyy' + }; + if (amount !== undefined) { + data.amount = amount; + } + const res = await this.ctx.post(`/fineract-provider/api/v1/clients/${clientId}/charges`, { data }); + return this.validateResponse(res, 'createClientCharge'); + } + + /** + * Runs a command against a client charge (`pay`, `waive`). + * @param clientId - The owning client id + * @param clientChargeId - The client charge id + * @param command - The command name + * @param data - The command payload + * @returns The Fineract command response payload + */ + async executeClientChargeCommand( + clientId: number, + clientChargeId: number, + command: string, + data: Record = {} + ): Promise { + const res = await this.ctx.post( + `/fineract-provider/api/v1/clients/${clientId}/charges/${clientChargeId}?command=${command}`, + { data } + ); + return this.validateResponse(res, `executeClientChargeCommand(${command})`); + } + + /** + * Deletes a client charge. + * @param clientId - The owning client id + * @param clientChargeId - The client charge id + */ + async deleteClientCharge(clientId: number, clientChargeId: number): Promise { + const res = await this.ctx.delete(`/fineract-provider/api/v1/clients/${clientId}/charges/${clientChargeId}`); + await this.validateResponse(res, 'deleteClientCharge'); + } + + // ── Client sub-entities (KYC tabs) ──────────────────────────────── + + /** + * Fetches the family-member template, which supplies the + * relationship / gender / profession / marital status dropdowns. + * @param clientId - The owning client id + * @returns The family member template payload + */ + async getFamilyMemberTemplate(clientId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/clients/${clientId}/familymembers/template`); + return this.validateResponse(res, 'getFamilyMemberTemplate'); + } + + /** + * Returns the identifiers attached to a client. + * @param clientId - The owning client id + * @returns The identifier collection + */ + async getClientIdentifiers(clientId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/clients/${clientId}/identifiers`); + const body = await this.validateResponse(res, 'getClientIdentifiers'); + return Array.isArray(body) ? body : []; + } + + /** + * Deletes a client identifier. + * @param clientId - The owning client id + * @param identifierId - The identifier id + */ + async deleteClientIdentifier(clientId: number, identifierId: number): Promise { + const res = await this.ctx.delete(`/fineract-provider/api/v1/clients/${clientId}/identifiers/${identifierId}`); + await this.validateResponse(res, 'deleteClientIdentifier'); + } + + /** + * Returns the notes attached to a client. + * @param clientId - The owning client id + * @returns The note collection, newest first as Fineract returns it + */ + async getClientNotes(clientId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/clients/${clientId}/notes`); + const body = await this.validateResponse(res, 'getClientNotes'); + return Array.isArray(body) ? body : []; + } + + /** + * Returns the addresses attached to a client. + * + * The address module is disabled by default in Fineract, in which + * case this endpoint 403s. Callers that only need to know whether + * addresses are usable should prefer {@link isAddressModuleEnabled}. + * + * @param clientId - The owning client id + * @returns The address collection + */ + async getClientAddresses(clientId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/client/${clientId}/addresses`); + const body = await this.validateResponse(res, 'getClientAddresses'); + return Array.isArray(body) ? body : []; + } + + /** + * Reports whether the optional address module is switched on for + * this tenant. + * + * Address is gated behind the `enable-address` global configuration, + * which ships **disabled**. When it is off the client view hides the + * tab entirely, so address specs must skip rather than fail — a + * switched-off optional module is not a product defect. Note the + * config name is lower-case; `Enable-Address` 404s. + * + * @returns true when the address module is enabled + */ + async isAddressModuleEnabled(): Promise { + const res = await this.ctx.get('/fineract-provider/api/v1/configurations/name/enable-address'); + if (!res.ok()) { + // Don't collapse a broken API or bad credentials into "module off" + // — that would make address specs silently skip instead of failing. + // The global config ships present, so any non-2xx here is unexpected. + throw new Error(`isAddressModuleEnabled: expected 2xx from the enable-address config, got ${res.status()}`); + } + const body = await res.json(); + return body?.enabled === true; + } + /** * Disposes the underlying Playwright request context. */ diff --git a/playwright/pages/client-view.page.ts b/playwright/pages/client-view.page.ts index ad8bd26350..d643a838b0 100644 --- a/playwright/pages/client-view.page.ts +++ b/playwright/pages/client-view.page.ts @@ -184,6 +184,63 @@ export class ClientViewPage extends BasePage { await this.actionMenuItem(name).click(); } + /** + * Returns the "Applications" submenu trigger inside the client + * actions menu — the entry point for opening a new loan, savings, + * share or deposit account. + * + * Only rendered for an ACTIVE client: the template wraps it in + * `@if (isActive())`. Opening it against a pending client is not a + * flake, it is Fineract's rule that accounts cannot be applied for + * until the client is activated. + */ + get applicationsMenuItem(): Locator { + return this.page.getByRole('menuitem', { name: 'Applications' }); + } + + /** + * Opens a new-account application from the client actions menu. + * + * Wraps the two-hop menu walk (Client actions → Applications → + * "New Savings Account") and waits for the create route so callers + * get a deterministic hand-off to the create page object. + * + * The React port renders the same logical menu through a shadcn + * dropdown, so this signature is unchanged there. + * + * @param name - Menu entry, e.g. `'New Savings Account'` or + * `'New Loan Account'`. + * @param urlPattern - Route the click is expected to land on, used + * to await navigation before returning. + */ + async openApplication(name: string, urlPattern: RegExp): Promise { + await this.openActionsMenu(); + await this.waitForVisible(this.applicationsMenuItem, 10000); + await this.applicationsMenuItem.hover(); + await this.applicationsMenuItem.click(); + + const entry = this.page.getByRole('menuitem', { name }); + await this.waitForVisible(entry, 10000); + await Promise.all([ + this.page.waitForURL(urlPattern, { timeout: 30000 }), + entry.click() + ]); + } + + /** + * Opens the new-savings-account stepper for this client. + */ + async openNewSavingsApplication(): Promise { + await this.openApplication('New Savings Account', new RegExp(`/clients/${this.clientId}/savings-accounts/create`)); + } + + /** + * Opens the new-loan-account stepper for this client. + */ + async openNewLoanApplication(): Promise { + await this.openApplication('New Loan Account', new RegExp(`/clients/${this.clientId}/loans-accounts/create`)); + } + /** * Opens the client actions menu and clicks the "Edit" entry. * diff --git a/playwright/pages/create-client.page.ts b/playwright/pages/create-client.page.ts index 06407db19a..9373280049 100644 --- a/playwright/pages/create-client.page.ts +++ b/playwright/pages/create-client.page.ts @@ -9,6 +9,7 @@ import { expect, Locator, Page } from '@playwright/test'; import { BasePage } from './BasePage'; +import { fillIfVisible, selectOption } from './material-form-helpers'; import { CREATE_CLIENT_SELECTORS } from '../config/selectors'; import { ROUTES } from '../config/routes'; @@ -556,15 +557,17 @@ export class CreateClientPage extends BasePage { /** * Opens a `` and picks the option whose visible text - * matches `optionLabel`. Wraps the click sequence required by - * Angular Material (open trigger → overlay option) so callers don't - * have to repeat it for every dropdown. + * matches `optionLabel`. + * + * Delegates to the shared `material-form-helpers` module so every + * Angular page object drives Material overlays identically. Kept as + * a thin private method (rather than inlining the helper at each + * call site) so the ~10 existing callers below stay unchanged, and + * so the React port can re-point this one line at its own + * `radix-form-helpers` module. */ private async selectMatOption(trigger: Locator, optionLabel: string): Promise { - await trigger.click(); - const option = this.page.getByRole('option', { name: optionLabel, exact: false }); - await this.waitForVisible(option.first(), 10000); - await option.first().click(); + await selectOption(this.page, trigger, optionLabel); } /** @@ -573,11 +576,6 @@ export class CreateClientPage extends BasePage { * based on tenant configuration. */ private async fillIfVisible(locator: Locator, value: string | undefined): Promise { - if (value === undefined) { - return; - } - if (await locator.isVisible()) { - await locator.fill(value); - } + await fillIfVisible(locator, value); } } diff --git a/playwright/pages/index.ts b/playwright/pages/index.ts index d587750f4f..88fdf44b49 100644 --- a/playwright/pages/index.ts +++ b/playwright/pages/index.ts @@ -24,6 +24,23 @@ */ export { BasePage } from './BasePage'; + +/** + * Angular Material overlay helpers. Exported from the pages barrel so + * new page objects reach for the shared implementation rather than + * re-deriving the trigger → overlay click sequence. The React port + * exports the same names from its own Radix-based module, so page + * objects swap by changing this barrel only. + */ +export { + selectOption, + selectFilteredOption, + fillDateField, + fillIfVisible, + confirmDialog, + type SelectOptionOptions +} from './material-form-helpers'; + export { LoginPage } from './login.page'; export { ClientsListPage } from './clients-list.page'; export { diff --git a/playwright/pages/material-form-helpers.spec.ts b/playwright/pages/material-form-helpers.spec.ts new file mode 100644 index 0000000000..97911108a3 --- /dev/null +++ b/playwright/pages/material-form-helpers.spec.ts @@ -0,0 +1,145 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; + +import { fillIfVisible, selectOption, selectFilteredOption, fillDateField } from './material-form-helpers'; + +// Pure-logic specs — they run under the `unit` Playwright project +// (testMatch includes /playwright\/pages\/.*\.spec\.ts/) with no +// browser and no app. The helpers only ever call methods on the +// `Page` / `Locator` objects they are handed, so a recording stub +// proves the interaction ORDER — which is the part that actually +// breaks when someone reimplements the overlay dance by hand. +test.use({ storageState: { cookies: [], origins: [] } }); + +/** Records every call made against the stubbed Playwright objects. */ +interface CallLog { + events: string[]; +} + +/** + * Build a stub `Locator` that records its interactions. + * + * @param log - Shared call log. + * @param name - Label used to prefix this locator's events. + * @param visible - Value returned by `isVisible()`. + */ +function stubLocator(log: CallLog, name: string, visible = true): Locator { + const locator = { + click: async () => { + log.events.push(`${name}.click`); + }, + fill: async (value: string) => { + log.events.push(`${name}.fill:${value}`); + }, + press: async (key: string) => { + log.events.push(`${name}.press:${key}`); + }, + blur: async () => { + log.events.push(`${name}.blur`); + }, + isVisible: async () => visible, + waitFor: async () => { + log.events.push(`${name}.waitFor`); + }, + first: () => locator + }; + return locator as unknown as Locator; +} + +/** + * Build a stub `Page` whose `getByRole('option', ...)` returns a + * recording locator, so tests can assert the overlay option was + * awaited before it was clicked. + */ +function stubPage(log: CallLog): Page { + return { + getByRole: (role: string, options: { name: string }) => stubLocator(log, `option[${role}:${options.name}]`) + } as unknown as Page; +} + +test.describe('selectOption()', () => { + test('opens the trigger, waits for the overlay, then clicks the option', async () => { + const log: CallLog = { events: [] }; + const page = stubPage(log); + const trigger = stubLocator(log, 'trigger'); + + await selectOption(page, trigger, 'Head Office'); + + // The waitFor MUST land between the trigger click and the option + // click. Material animates the overlay in, so clicking without + // the wait races the animation and silently lands on nothing. + expect(log.events).toEqual([ + 'trigger.click', + 'option[option:Head Office].waitFor', + 'option[option:Head Office].click' + ]); + }); +}); + +test.describe('selectFilteredOption()', () => { + test('types into the filter box before selecting', async () => { + const log: CallLog = { events: [] }; + const page = stubPage(log); + const trigger = stubLocator(log, 'trigger'); + const searchInput = stubLocator(log, 'search'); + + await selectFilteredOption(page, trigger, searchInput, 'E2E Loan Product'); + + // The filter must be populated before the option is awaited — the + // loan-product dropdown does not render an option until its + // search term matches. + expect(log.events).toEqual([ + 'trigger.click', + 'search.waitFor', + 'search.fill:E2E Loan Product', + 'option[option:E2E Loan Product].waitFor', + 'option[option:E2E Loan Product].click' + ]); + }); +}); + +test.describe('fillDateField()', () => { + test('fills then blurs so Material commits the value', async () => { + const log: CallLog = { events: [] }; + const input = stubLocator(log, 'date'); + + await fillDateField(input, '01 January 2024'); + + // Blur fires dateChange so Angular form controls commit. In unit + // stubs there is no page-level overlay to close, so the helper + // falls back to Escape on the input itself. + expect(log.events).toEqual([ + 'date.fill:01 January 2024', + 'date.blur', + 'date.press:Escape' + ]); + }); +}); + +test.describe('fillIfVisible()', () => { + test('fills a visible field', async () => { + const log: CallLog = { events: [] }; + await fillIfVisible(stubLocator(log, 'field', true), 'value'); + expect(log.events).toEqual(['field.fill:value']); + }); + + test('skips a hidden field without throwing', async () => { + const log: CallLog = { events: [] }; + await fillIfVisible(stubLocator(log, 'field', false), 'value'); + expect(log.events).toEqual([]); + }); + + test('treats undefined as a no-op even when the field is visible', async () => { + const log: CallLog = { events: [] }; + await fillIfVisible(stubLocator(log, 'field', true), undefined); + expect(log.events).toEqual([]); + }); +}); diff --git a/playwright/pages/material-form-helpers.ts b/playwright/pages/material-form-helpers.ts new file mode 100644 index 0000000000..34c4efd673 --- /dev/null +++ b/playwright/pages/material-form-helpers.ts @@ -0,0 +1,246 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Layer 3 — Angular Material form-interaction helpers. + * + * Angular Material renders `` options, datepicker + * calendars, and dialogs into a CDK overlay that lives OUTSIDE the + * component's DOM subtree. Driving any of them therefore takes a + * two-step click sequence (open the trigger, then act on the overlay) + * plus an explicit visibility wait, because the overlay is animated in. + * Repeating that sequence inline in every page object is how the + * suite would end up with a dozen subtly-different dropdown helpers. + * + * ── Why these are standalone functions, not `BasePage` methods ────── + * + * `BasePage` is deliberately framework-agnostic: its docstring states + * that staying free of Angular/Material specifics is exactly what lets + * the same class back both the Angular and React page objects + * (proposal §8). A `selectMatOption` method on `BasePage` would break + * that contract — `mat-option` means nothing to a React port built on + * Radix/ShadCN, and a React contributor inheriting the method would + * have no way to act on it. + * + * So the swap point stays where §8 puts it: at the framework-specific + * layer. The React port ships its own `radix-form-helpers.ts` exporting + * the SAME function signatures (`selectOption`, `fillDateField`, + * `fillIfVisible`, …). Page objects change one import path; their + * method bodies and public API do not change at all. + * + * ── Usage ─────────────────────────────────────────────────────────── + * + * ```ts + * import { selectOption, fillIfVisible } from './material-form-helpers'; + * + * export class CreateSavingsAccountPage extends BasePage { + * async selectProduct(name: string): Promise { + * await selectOption(this.page, this.productDropdown, name); + * } + * } + * ``` + */ + +import { expect, Locator, Page } from '@playwright/test'; + +/** Default wait for a CDK overlay to animate in. */ +const DEFAULT_OVERLAY_TIMEOUT_MS = 10000; + +/** Options accepted by {@link selectOption}. */ +export interface SelectOptionOptions { + /** Overlay visibility timeout in ms. Defaults to 10 000. */ + timeout?: number; + /** + * Require the option label to match exactly. Defaults to `false`, + * because Material option text is frequently padded by the + * `translateKey` pipe and by nested `` whitespace. + */ + exact?: boolean; +} + +/** + * Open a `` and pick the option whose visible text matches + * `optionLabel`. + * + * Waits for the overlay option to become visible before clicking — + * Material animates the overlay in, so an immediate click races the + * animation and lands on nothing. + * + * @param page - The Playwright page, needed to reach the CDK overlay + * which is rendered outside the component subtree. + * @param trigger - Locator for the `` element itself. + * @param optionLabel - Visible text of the option to pick. + * @param options - See {@link SelectOptionOptions}. + */ +export async function selectOption( + page: Page, + trigger: Locator, + optionLabel: string, + options: SelectOptionOptions = {} +): Promise { + const timeout = options.timeout ?? DEFAULT_OVERLAY_TIMEOUT_MS; + await trigger.click(); + const option = page.getByRole('option', { name: optionLabel, exact: options.exact ?? false }); + await option.first().waitFor({ state: 'visible', timeout }); + await option.first().click(); +} + +/** + * Open a searchable `` (one wrapped in `mat-select-filter`), + * type into its filter box, then pick the matching option. + * + * The loan-product dropdown on the create-loan-account stepper uses + * this pattern, and its options are keyed by product NAME rather than + * id — so the plain {@link selectOption} path cannot reach an option + * that the filter has not yet revealed. + * + * @param page - The Playwright page. + * @param trigger - Locator for the `` element. + * @param searchInput - Locator for the filter input inside the overlay. + * @param optionLabel - Visible text of the option to pick; also used + * as the search term. + * @param options - See {@link SelectOptionOptions}. + */ +export async function selectFilteredOption( + page: Page, + trigger: Locator, + searchInput: Locator, + optionLabel: string, + options: SelectOptionOptions = {} +): Promise { + const timeout = options.timeout ?? DEFAULT_OVERLAY_TIMEOUT_MS; + await trigger.click(); + await searchInput.waitFor({ state: 'visible', timeout }); + await searchInput.fill(optionLabel); + + const option = page.getByRole('option', { name: optionLabel, exact: options.exact ?? false }); + await option.first().waitFor({ state: 'visible', timeout }); + await option.first().click(); +} + +/** + * Fill a Material datepicker input by typing the formatted date. + * + * Typing beats driving the calendar popup: the popup requires N clicks + * to reach an arbitrary month, and its markup differs between Material + * versions. The trade-off is that the string must match the tenant's + * configured display format (`DD MMMM YYYY` in the Angular app's + * `dateFormat` setting) — which is NOT the same string as the API's + * `dd MMMM yyyy` wire format, even though both render identically. + * + * ── Why the calendar is explicitly dismissed ──────────────────────── + * + * Mifos wraps most date fields in + * ``, so *any* interaction + * with the field pops the calendar. That overlay is attached to the + * document root with a full-viewport backdrop, and it does not close + * on blur — so it silently intercepts the next click anywhere on the + * page. The symptom is a timeout on a completely unrelated control + * ("Next button … … + * intercepts pointer events"), which is a genuinely hard failure to + * trace back to the date field that caused it. + * + * Dismissing here, once, keeps that class of failure out of every + * calling page object. + * + * @param input - Locator for the datepicker's ``. + * @param value - Date string in the tenant's display format. + */ +export async function fillDateField(input: Locator, value: string): Promise { + await input.fill(value); + // Blur so Material's `dateInput`/`dateChange` events fire and the + // bound form control updates. Without this the control can stay + // pristine and leave the step's Next button disabled. + await input.blur(); + + // In browser-backed runs, a real Playwright Locator exposes + // `page()`. Unit stubs in this repository intentionally implement + // only the methods under test, so fall back to Escape on the input + // itself when `page()` is unavailable. + const pageGetter = (input as unknown as { page?: () => Page }).page; + if (typeof pageGetter === 'function') { + await dismissDatepickerOverlay(pageGetter.call(input)); + return; + } + + await input.press('Escape').catch(() => undefined); +} + +/** + * Close an open Material datepicker calendar, if one is showing. + * + * Exported because a few flows open the calendar without going through + * {@link fillDateField} (clicking the suffix toggle directly, for + * instance) and still need the overlay gone before the next click. + * + * Safe to call unconditionally — it is a no-op when no calendar is + * open, and it swallows the wait timeout rather than failing the test, + * since a still-open calendar will surface as a much more specific + * error at the actual click site. + * + * @param page - The Playwright page. + * @param timeout - How long to wait for the overlay to detach. + */ +export async function dismissDatepickerOverlay(page: Page, timeout = 5000): Promise { + // `.first()` — while an earlier overlay pane is still detaching there + // can be two `.mat-datepicker-content` nodes; an unscoped locator then + // throws a strict-mode error that `.catch(() => false)` would misread + // as "no calendar open", skipping the Escape this helper exists to send. + const calendar = page.locator('.mat-datepicker-content').first(); + if (!(await calendar.isVisible().catch(() => false))) { + return; + } + await page.keyboard.press('Escape'); + await calendar.waitFor({ state: 'hidden', timeout }).catch(() => undefined); +} + +/** + * Fill an input only when it is currently visible. + * + * Several Mifos forms render fields conditionally on tenant + * configuration — the client address dialog is built from + * `/fieldconfiguration/ADDRESS`, and the add-charge form swaps its + * date controls based on the selected charge's time type. A helper + * that no-ops on an absent field keeps page objects free of + * `if (await x.isVisible())` noise. + * + * Passing `undefined` is also a no-op, so callers can forward optional + * data fields directly. + * + * @param locator - Locator for the input. + * @param value - Value to fill, or `undefined` to skip. + */ +export async function fillIfVisible(locator: Locator, value: string | undefined): Promise { + if (value === undefined) { + return; + } + if (await locator.isVisible()) { + await locator.fill(value); + } +} + +/** + * Click a button inside an open ``, then wait for the + * dialog to close. + * + * Many client sub-entity flows (address, identifiers, documents, note + * edit) submit through a dialog. Asserting the dialog is gone before + * returning prevents the next action from clicking through a + * still-animating overlay backdrop. + * + * @param page - The Playwright page. + * @param buttonName - Accessible name of the dialog button, e.g. `'Add'`. + * @param options - Optional dismissal timeout in ms. Defaults to 10 000. + */ +export async function confirmDialog(page: Page, buttonName: string, options: { timeout?: number } = {}): Promise { + const timeout = options.timeout ?? DEFAULT_OVERLAY_TIMEOUT_MS; + const dialog = page.locator('mat-dialog-container'); + await dialog.waitFor({ state: 'visible', timeout }); + await dialog.getByRole('button', { name: buttonName }).first().click(); + await expect(dialog).toBeHidden({ timeout }); +} diff --git a/playwright/tests/clients/create-client.spec.ts b/playwright/tests/clients/create-client.spec.ts index 35555d5275..1061d37126 100644 --- a/playwright/tests/clients/create-client.spec.ts +++ b/playwright/tests/clients/create-client.spec.ts @@ -14,7 +14,7 @@ import { type GeneralStepData, type FamilyMemberData } from '../../pages'; -import { createTestClient } from '../../factories'; +import { buildTestClientPayload } from '../../factories'; import { BEHAVIOR } from '../../config/behavior'; /** @@ -28,7 +28,7 @@ import { BEHAVIOR } from '../../config/behavior'; * single test exercises every PR-1 page object surface * (`ClientsListPage`, `CreateClientPage`, `ClientViewPage`). * - * 2. Negative path — uses the {@link createTestClient} payload factory + * 2. Negative path — uses the {@link buildTestClientPayload} payload factory * to build a baseline that is one required field short, then * asserts the wizard refuses to expose the Preview step until the * missing field is supplied. The factory is intentionally scoped @@ -216,7 +216,7 @@ test.describe('Create Client — CRUD', () => { ); } - const invalidPayload = createTestClient({ + const invalidPayload = buildTestClientPayload({ office: officeName, lastname: '' }); diff --git a/playwright/tests/clients/lifecycle/illegal-transitions.spec.ts b/playwright/tests/clients/lifecycle/illegal-transitions.spec.ts new file mode 100644 index 0000000000..773525f9c8 --- /dev/null +++ b/playwright/tests/clients/lifecycle/illegal-transitions.spec.ts @@ -0,0 +1,224 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * WEB-XXXX — Client-lifecycle negative-path / illegal-transitions matrix. + * + * Locks down Fineract's client state machine as a *data table*. Each + * row expresses an illegal predecessor → command pairing and asserts + * two invariants: + * + * 1. The command surfaces an error string — the same payload the UI + * snackbar renders (Fineract `userMessageGlobalisationCode` / + * `developerMessage`). Matched with a row-specific regex so a + * copy change in Fineract shows up as a targeted CI failure + * rather than a silent green. + * 2. The client's server-side `status.value` is *unchanged* after + * the failed attempt. This is the state-machine invariant: a + * rejected command must never leave the aggregate in an + * intermediate state. + * + * Why API-only (no UI): the client action menu (`ClientViewPage. + * chooseAction`) is state-aware and hides illegal actions, so a UI + * driver *cannot* even attempt these transitions. Fineract's REST + * layer is the authoritative enforcement point — locking it here is + * what makes "future Fineract command additions either fit the table + * or fail in CI" true. + * + * Loop shape: Playwright has no `test.describe.each`; the equivalent + * idiom is a plain `for (const row of MATRIX) { test.describe(...) }` + * declared at module scope. Playwright collects the tests during + * registration, so shard/parallel semantics are identical to + * hand-written describes. + */ + +import { test, expect } from '../../../fixtures/test-fixtures'; +import { createTestClient } from '../../../factories/client.factory'; +import { generateE2EName } from '../../../utils/naming'; +import type { FineractApiClient } from '../../../fixtures/fineract-api'; +import type { ApiSetupManager } from '../../../utils/api-setup-manager'; +import type { CleanupGuard } from '../../../utils/cleanup-guard'; + +const SUBMITTED_ON_DATE = '01 January 2024'; +const ACTIVATION_DATE = '02 January 2024'; +const CLOSURE_DATE = '03 January 2024'; +const ILLEGAL_ATTEMPT_DATE = '05 January 2024'; +const DEFAULT_DATE_FORMAT = 'dd MMMM yyyy'; +const DEFAULT_LOCALE = 'en'; + +const CLOSURE_REASON_NAME = 'E2E Close Client Reason'; +const REJECTION_REASON_NAME = 'E2E Reject Client Reason'; + +/** + * Predecessor-state builder. Returns the id of a freshly-created + * client already parked in `expectedState`. Cleanup for non-pending + * predecessors is intentionally NOT registered — Fineract only hard- + * deletes pending clients, and the existing lifecycle specs + * (`close-client.spec.ts`, `reactivate-after-close.spec.ts`) follow + * the same trade-off. + */ +type StateBuilder = ( + fineractApi: FineractApiClient, + apiSetup: ApiSetupManager, + cleanupGuard: CleanupGuard +) => Promise; + +/** + * Illegal-command builder. Produces the payload for the illegal + * command attempt. Kept as a builder (not a static object) because + * reject/reactivate need a freshly-resolved code-value id that must + * be looked up per-run. + */ +type PayloadBuilder = (fineractApi: FineractApiClient) => Promise>; + +interface IllegalTransitionRow { + /** Human-readable row label used in the `describe` title. */ + readonly name: string; + /** Predecessor state as reported by `GET /clients/{id}.status.value`. */ + readonly fromStatusValue: string; + /** Fineract command name (`?command=...`). */ + readonly command: string; + /** Builds a client parked in `fromStatusValue`. */ + readonly buildPredecessor: StateBuilder; + /** Builds the illegal command payload. */ + readonly buildPayload: PayloadBuilder; + /** + * Regex that must match the Fineract error body. Anchored on the + * `userMessageGlobalisationCode` when Fineract exposes one + * (see `InvalidClientStateTransitionException`) and the + * developer-facing message string as a fallback. Both surfaces + * appear in the JSON envelope the UI reads for the snackbar. + */ + readonly expectedErrorPattern: RegExp; +} + +const MATRIX: readonly IllegalTransitionRow[] = [ + { + name: 'Active → activate is rejected (already active)', + fromStatusValue: 'Active', + command: 'activate', + buildPredecessor: async (fineractApi) => { + const officeId = await fineractApi.getFirstOfficeId(); + const createResponse = await fineractApi.createActiveClient(officeId, { + firstname: generateE2EName('illegalActive'), + lastname: 'Client', + submittedOnDate: SUBMITTED_ON_DATE, + activationDate: ACTIVATION_DATE + }); + return createResponse.resourceId ?? createResponse.clientId; + }, + buildPayload: async () => ({ + activationDate: ILLEGAL_ATTEMPT_DATE, + dateFormat: DEFAULT_DATE_FORMAT, + locale: DEFAULT_LOCALE + }), + // Fineract's `Client.activate()` checks `isActive()` and throws + // `PlatformApiDataValidationException` with globalisation code + // `error.msg.clients.already.active` and message "Cannot activate + // client. Client is already active." + expectedErrorPattern: /already.active|Cannot activate client/i + }, + { + name: 'Pending → reactivate is rejected', + fromStatusValue: 'Pending', + command: 'reactivate', + buildPredecessor: async (_fineractApi, apiSetup, cleanupGuard) => { + const client = await createTestClient(apiSetup, cleanupGuard, { + submittedOnDate: SUBMITTED_ON_DATE + }); + return client.resourceId; + }, + buildPayload: async () => ({ + reactivationDate: ILLEGAL_ATTEMPT_DATE, + dateFormat: DEFAULT_DATE_FORMAT, + locale: DEFAULT_LOCALE + }), + // Source: `ClientWritePlatformServiceJpaRepositoryImpl.reActivateClient` + // throws `InvalidClientStateTransitionException("reactivation", + // "on.nonclosed.account", "only closed clients may be reactivated.")` + // which the `InvalidClientStateTransitionException` constructor + // renders as globalisation code + // `error.msg.client.reactivation.on.nonclosed.account`. + expectedErrorPattern: /reactivation\.on\.nonclosed\.account|only closed clients may be reactivated/i + }, + { + name: 'Active → reject is rejected', + fromStatusValue: 'Active', + command: 'reject', + buildPredecessor: async (fineractApi) => { + // Seed the reject-reason code value up front so the illegal + // command payload can reference it — Fineract validates the + // reason id BEFORE the state guard fires, so an unknown id + // would produce the wrong error class and falsely pass the + // expectedErrorPattern. + await fineractApi.ensureClientRejectionReason(REJECTION_REASON_NAME); + const officeId = await fineractApi.getFirstOfficeId(); + const createResponse = await fineractApi.createActiveClient(officeId, { + firstname: generateE2EName('illegalActive'), + lastname: 'Client', + submittedOnDate: SUBMITTED_ON_DATE, + activationDate: ACTIVATION_DATE + }); + return createResponse.resourceId ?? createResponse.clientId; + }, + buildPayload: async (fineractApi) => { + const reason = await fineractApi.ensureClientRejectionReason(REJECTION_REASON_NAME); + return { + rejectionDate: ILLEGAL_ATTEMPT_DATE, + rejectionReasonId: reason.id, + dateFormat: DEFAULT_DATE_FORMAT, + locale: DEFAULT_LOCALE + }; + }, + // Source: `ClientWritePlatformServiceJpaRepositoryImpl.rejectClient` + // throws `InvalidClientStateTransitionException("rejection", + // "on.account.not.in.pending.activation.status", + // "Only clients pending activation may be withdrawn.")` → + // globalisation code + // `error.msg.client.rejection.on.account.not.in.pending.activation.status`. + expectedErrorPattern: /rejection\.on\.account\.not\.in\.pending\.activation\.status|clients pending activation/i + } +]; + +test.describe('Client lifecycle · Illegal transitions matrix', () => { + for (const row of MATRIX) { + test.describe(row.name, () => { + test(`rejects '${row.command}' from ${row.fromStatusValue} and leaves status unchanged`, async ({ + fineractApi, + apiSetup, + cleanupGuard + }) => { + const clientId = await row.buildPredecessor(fineractApi, apiSetup, cleanupGuard); + + // Sanity-check the predecessor state BEFORE the illegal + // attempt — a bug in the setup helper (e.g. a silent no-op + // close) would otherwise turn this test into a tautology. + const before = await fineractApi.getClient(clientId); + expect(before.status?.value).toBe(row.fromStatusValue); + + const payload = await row.buildPayload(fineractApi); + const result = await fineractApi.tryExecuteClientCommand(clientId, row.command, payload); + + // Assertion 1 — the wire-level error surface. + expect( + result.ok, + `Fineract accepted an illegal ${row.fromStatusValue} → ${row.command} transition ` + + `(status ${result.status}, body: ${result.bodyText})` + ).toBe(false); + expect(result.status).toBeGreaterThanOrEqual(400); + expect(result.bodyText).toMatch(row.expectedErrorPattern); + + // Assertion 2 — state-machine invariant. The rejected + // command must NOT have mutated `status.value`. Re-fetch + // via GET (never trust the response of the failed POST). + const after = await fineractApi.getClient(clientId); + expect(after.status?.value).toBe(row.fromStatusValue); + }); + }); + } +}); diff --git a/playwright/types/test-data.types.ts b/playwright/types/test-data.types.ts index cfd9759b38..6eb298a5fd 100644 --- a/playwright/types/test-data.types.ts +++ b/playwright/types/test-data.types.ts @@ -59,14 +59,30 @@ export interface TestClientTimeline { closedOnDate?: readonly number[]; } -/** Narrow loan-timeline projection — every field optional. */ +/** + * Narrow loan-timeline projection — every field optional. + * + * `expectedDisbursementDate` / `actualDisbursementDate` are the names + * Fineract actually returns on `GET /loans/{id}`; `disbursedOnDate` is + * kept for callers written against the older projection. + */ export interface TestLoanTimeline { submittedOnDate?: readonly number[]; approvedOnDate?: readonly number[]; + expectedDisbursementDate?: readonly number[]; + actualDisbursementDate?: readonly number[]; disbursedOnDate?: readonly number[]; closedOnDate?: readonly number[]; } +/** Narrow savings-account-timeline projection — every field optional. */ +export interface TestSavingsTimeline { + submittedOnDate?: readonly number[]; + approvedOnDate?: readonly number[]; + activatedOnDate?: readonly number[]; + closedOnDate?: readonly number[]; +} + /** Created Fineract client as seen by E2E specs. */ export interface TestClient extends TestEntityIdentity { officeId: number; @@ -102,3 +118,33 @@ export interface TestLoan extends TestEntityIdentity { status?: TestStatus; timeline?: TestLoanTimeline; } + +/** Created Fineract savings account as seen by E2E specs. */ +export interface TestSavingsAccount extends TestEntityIdentity { + clientId: number; + savingsProductId: number; + status?: TestStatus; + timeline?: TestSavingsTimeline; +} + +/** + * Created Fineract loan product as seen by E2E specs. + * + * Products are shared, tenant-level infrastructure rather than + * per-test data — see `ensureMinimalLoanProduct` in + * `fixtures/fineract-api.ts` — so this shape omits a `displayName` + * projection and instead carries the two fields specs actually match + * against (`name`, `shortName`). + */ +export interface TestLoanProduct { + resourceId: number; + name: string; + shortName: string; +} + +/** Created Fineract savings product as seen by E2E specs. */ +export interface TestSavingsProduct { + resourceId: number; + name: string; + shortName: string; +} diff --git a/playwright/utils/api-setup-manager.spec.ts b/playwright/utils/api-setup-manager.spec.ts index 21902a19f7..00e4a098a3 100644 --- a/playwright/utils/api-setup-manager.spec.ts +++ b/playwright/utils/api-setup-manager.spec.ts @@ -466,3 +466,79 @@ test.describe('getClientTemplate domain wrapper', () => { expect(result).toEqual({ offices: ['recovered'] }); }); }); + +// ───────────────────────────────────────────────────────────────────── +// Product seeding wrappers +// ───────────────────────────────────────────────────────────────────── + +test.describe('ApiSetupManager product wrappers', () => { + test('ensureMinimalSavingsProduct() creates the product at most once', async () => { + let calls = 0; + const stub = { + ensureMinimalSavingsProduct: async () => { + calls += 1; + return { id: 7, name: 'E2E Savings Product' }; + } + } as unknown as FineractApiClient; + const manager = new ApiSetupManager(stub, { cache: new Map() }); + + // Concurrent callers must share the in-flight promise, and later + // sequential callers must hit the resolved cache entry. This is + // what keeps a 15-spec shard from issuing 15 product creates. + const [ + a, + b + ] = await Promise.all([ + manager.ensureMinimalSavingsProduct(), + manager.ensureMinimalSavingsProduct() + ]); + const c = await manager.ensureMinimalSavingsProduct(); + + expect(calls).toBe(1); + expect(a).toEqual({ id: 7, name: 'E2E Savings Product' }); + expect(b).toBe(a); + expect(c).toBe(a); + }); + + test('loan and savings product wrappers use distinct cache keys', async () => { + const stub = { + ensureMinimalLoanProduct: async () => ({ id: 1, name: 'E2E Loan Product' }), + ensureMinimalSavingsProduct: async () => ({ id: 2, name: 'E2E Savings Product' }) + } as unknown as FineractApiClient; + const manager = new ApiSetupManager(stub, { cache: new Map() }); + + expect(await manager.ensureMinimalLoanProduct()).toEqual({ id: 1, name: 'E2E Loan Product' }); + expect(await manager.ensureMinimalSavingsProduct()).toEqual({ id: 2, name: 'E2E Savings Product' }); + }); + + test('a failed product create is evicted so the next caller retries', async () => { + let calls = 0; + const stub = { + ensureMinimalSavingsProduct: async () => { + calls += 1; + if (calls === 1) throw new Error('duplicate race lost'); + return { id: 9 }; + } + } as unknown as FineractApiClient; + const manager = new ApiSetupManager(stub, { cache: new Map() }); + + await expect(manager.ensureMinimalSavingsProduct()).rejects.toThrow('duplicate race lost'); + expect(await manager.ensureMinimalSavingsProduct()).toEqual({ id: 9 }); + expect(calls).toBe(2); + }); + + test('getSavingsProductTemplate() is fetched once per run', async () => { + let calls = 0; + const stub = { + getSavingsProductTemplate: async () => { + calls += 1; + return { interestPostingPeriodTypeOptions: [{ id: 4, value: 'Monthly' }] }; + } + } as unknown as FineractApiClient; + const manager = new ApiSetupManager(stub, { cache: new Map() }); + + await manager.getSavingsProductTemplate(); + await manager.getSavingsProductTemplate(); + expect(calls).toBe(1); + }); +}); diff --git a/playwright/utils/api-setup-manager.ts b/playwright/utils/api-setup-manager.ts index 637eba1821..5fcbc96b83 100644 --- a/playwright/utils/api-setup-manager.ts +++ b/playwright/utils/api-setup-manager.ts @@ -186,6 +186,45 @@ export class ApiSetupManager { return this.dedupe(key, () => this.api.getClientTemplate(officeId)); } + /** + * Returns (and caches) the shared minimal loan product, creating it + * on first use. + * + * Products are shared infrastructure rather than per-test data, so a + * whole run pays for at most one create. Without this wrapper every + * loan factory invocation would re-issue the product list GET (and + * race on the POST) even though the answer never changes. + * + * @returns The existing or created loan product + */ + ensureMinimalLoanProduct(): Promise { + return this.dedupe('loanProduct:minimal', () => this.api.ensureMinimalLoanProduct()); + } + + /** + * Returns (and caches) the shared minimal savings product, creating + * it on first use. Savings counterpart of + * {@link ensureMinimalLoanProduct} — see that method for the + * rationale. + * + * @returns The existing or created savings product + */ + ensureMinimalSavingsProduct(): Promise { + return this.dedupe('savingsProduct:minimal', () => this.api.ensureMinimalSavingsProduct()); + } + + /** + * Returns (and caches) the savings product creation template. + * + * The template is tenant-global and carries the enum option lists + * used to build savings payloads, so one fetch serves the whole run. + * + * @returns The savings product template payload + */ + getSavingsProductTemplate(): Promise { + return this.dedupe('savingsProductTemplate', () => this.api.getSavingsProductTemplate()); + } + /** * Test-only escape hatch. Clears the cache backing this instance — * useful for unit specs that want a clean slate per `test()` diff --git a/playwright/utils/cleanup-guard.spec.ts b/playwright/utils/cleanup-guard.spec.ts index adda5a987d..65e48a8238 100644 --- a/playwright/utils/cleanup-guard.spec.ts +++ b/playwright/utils/cleanup-guard.spec.ts @@ -73,6 +73,41 @@ test.describe('CleanupGuard.flush() ordering', () => { expect(summary.failed).toEqual([]); }); + /** + * Regression guard: an earlier implementation snapshotted the stack + * in LIFO order but then dispatched every deleter in the same tick + * via `Promise.allSettled`. Ordering was therefore only apparent — + * a slow child deleter could still have its DELETE reach Fineract + * after its parent's, producing an FK violation. Staggered delays + * make a concurrent drain observably interleave. + */ + test('awaits each deleter before starting the next', async () => { + const guard = new CleanupGuard(); + const events: string[] = []; + + const slowDeleter = (label: string, delayMs: number) => async () => { + events.push(`start:${label}`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + events.push(`end:${label}`); + }; + + // Registered parent-first, so LIFO must run 'child' to completion + // before 'parent' even starts. The child is deliberately the + // slower of the two — under a concurrent drain 'parent' would + // finish first and the interleaving would be visible. + guard.register('parent', slowDeleter('parent', 1)); + guard.register('child', slowDeleter('child', 25)); + + await guard.flush(); + + expect(events).toEqual([ + 'start:child', + 'end:child', + 'start:parent', + 'end:parent' + ]); + }); + test('drains the stack to zero after a flush', async () => { const guard = new CleanupGuard(); guard.register('a', async () => undefined); diff --git a/playwright/utils/cleanup-guard.ts b/playwright/utils/cleanup-guard.ts index 2b88532521..f92fca9e0d 100644 --- a/playwright/utils/cleanup-guard.ts +++ b/playwright/utils/cleanup-guard.ts @@ -15,11 +15,15 @@ * A per-test `afterEach` hook is too easy to forget; the `cleanupGuard` * auto-fixture in `playwright/fixtures/test-fixtures.ts` calls * {@link CleanupGuard.flush} unconditionally so opt-in is impossible. - * - Deleters MUST run in strict reverse-insertion order (LIFO). A test - * that creates a client and then a loan on that client cannot delete - * the client before the loan or Fineract rejects the cascade. + * - Deleters MUST run in strict reverse-insertion order (LIFO), one + * at a time. A test that creates a client and then a loan on that + * client cannot delete the client before the loan or Fineract + * rejects the cascade with a 403. Ordering is only meaningful if + * the drain is *sequential* — issuing every delete concurrently + * and merely sorting the array does not stop the client DELETE + * from reaching Fineract before the loan DELETE lands. * - A single failing deleter MUST NOT abort the remaining deleters. - * The whole flush is wrapped in `Promise.allSettled` so a flaky + * Each deleter is awaited inside its own try/catch so a flaky * teardown HTTP call cannot leak the siblings — and the structured * {@link FlushSummary} surfaces every failure for triage. * - flush() NEVER throws. Teardown noise must not mask the real test @@ -49,9 +53,11 @@ export interface FlushOutcome { /** Human-readable label supplied at register-time, for triage logs. */ label: string; /** - * Result of the underlying `Promise.allSettled`. `'fulfilled'` means - * the deleter resolved (Fineract returned 2xx); `'rejected'` means - * it threw or returned a non-2xx response. + * Outcome of this deleter's own awaited invocation. `'fulfilled'` + * means it resolved (Fineract returned 2xx); `'rejected'` means it + * threw or returned a non-2xx response. Each deleter is awaited + * individually inside {@link CleanupGuard.flush}, so this reflects + * one delete, not a batched settle. */ status: 'fulfilled' | 'rejected'; /** Populated only when `status === 'rejected'`. */ @@ -159,8 +165,18 @@ export class CleanupGuard { } /** - * Drain the stack in strict LIFO order, running every deleter via - * `Promise.allSettled` so a single failure cannot block siblings. + * Drain the stack in strict LIFO order, awaiting each deleter + * before starting the next so dependent resources are removed in + * the only sequence Fineract's foreign keys accept. + * + * Sequential — not concurrent — by design. Factories register a + * sub-entity's deleter *after* its parent's, so LIFO pops the child + * first; that guarantee is void if every delete is dispatched in + * the same tick. See the module docstring for the full rationale. + * + * A rejecting deleter is caught and recorded, then the drain + * continues with the next entry — one dead resource must not leak + * its siblings. * * Safe to call multiple times: subsequent calls after the stack is * drained resolve to an empty {@link FlushSummary} (`ok: 0`, @@ -190,22 +206,18 @@ export class CleanupGuard { drained.push(this.stack.pop()!); } - const settled = await Promise.allSettled( - // Each deleter is wrapped in `Promise.resolve().then(...)` so - // that a synchronously-thrown error becomes a rejected - // promise. Without this wrapper a sync throw would propagate - // out of the `.map()` callback BEFORE `Promise.allSettled` - // could intercept it and the whole flush would reject — the - // exact failure mode this design is meant to prevent. - drained.map((entry) => Promise.resolve().then(() => entry.deleter())) - ); - - const outcomes: FlushOutcome[] = settled.map((result, index) => { - const label = drained[index].label; - return result.status === 'fulfilled' - ? { label, status: 'fulfilled' } - : { label, status: 'rejected', reason: result.reason }; - }); + const outcomes: FlushOutcome[] = []; + for (const entry of drained) { + try { + // `Promise.resolve().then(...)` normalises a deleter that + // throws synchronously into a rejected promise, so the + // catch below handles both failure shapes identically. + await Promise.resolve().then(() => entry.deleter()); + outcomes.push({ label: entry.label, status: 'fulfilled' }); + } catch (reason) { + outcomes.push({ label: entry.label, status: 'rejected', reason }); + } + } const failed = outcomes .filter((o): o is FlushOutcome & { status: 'rejected' } => o.status === 'rejected') diff --git a/scripts/add-file-headers.js b/scripts/add-file-headers.js index 1f3d66236f..9a8cd155ce 100644 --- a/scripts/add-file-headers.js +++ b/scripts/add-file-headers.js @@ -63,6 +63,7 @@ const EXCLUDE_PATTERNS = [ 'version.js', 'environment.ts', 'environment.prod.ts', + '.env.ts', 'polyfills.ts', 'typings.d.ts', 'global.d.ts', diff --git a/scripts/check-file-headers.js b/scripts/check-file-headers.js index 0aa2f111a7..13a4ab6be0 100644 --- a/scripts/check-file-headers.js +++ b/scripts/check-file-headers.js @@ -64,6 +64,7 @@ const EXCLUDE_PATTERNS = [ 'version.js', 'environment.ts', 'environment.prod.ts', + '.env.ts', 'polyfills.ts', 'typings.d.ts', 'global.d.ts', diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index f62f952fba..e082705c1a 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -23,6 +23,8 @@ import { IdentitiesTabComponent } from './clients-view/identities-tab/identities import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component'; import { BureauReadinessComponent } from './clients-view/bureau-readiness/bureau-readiness.component'; import { CreditProfileComponent } from './clients-view/credit-profile/credit-profile.component'; +import { DisputeManagementComponent } from './clients-view/dispute-management/dispute-management.component'; +import { AuditTrailComponent } from './clients-view/audit-trail/audit-trail.component'; import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component'; import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component'; import { AddressTabComponent } from './clients-view/address-tab/address-tab.component'; @@ -187,6 +189,16 @@ const routes: Routes = [ component: CreditProfileComponent, data: { title: 'Credit Profile', breadcrumb: 'Credit Profile', routeParamBreadcrumb: false } }, + { + path: 'dispute-management', + component: DisputeManagementComponent, + data: { title: 'Dispute Management', breadcrumb: 'Dispute Management', routeParamBreadcrumb: false } + }, + { + path: 'audit-trail', + component: AuditTrailComponent, + data: { title: 'Audit Trail', breadcrumb: 'Audit Trail', routeParamBreadcrumb: false } + }, { path: 'datatables', children: [ diff --git a/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.spec.ts b/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.spec.ts index 62026a31e6..df45d7c02f 100644 --- a/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.spec.ts +++ b/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.spec.ts @@ -8,7 +8,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; import * as L from 'leaflet'; import { AddressLocationMapComponent } from './address-location-map.component'; @@ -19,6 +19,9 @@ const mockMapInvalidateSize = jest.fn(); const mockMarkerSetLatLng = jest.fn(); const mockTileLayerAddTo = jest.fn().mockReturnThis(); let mockTileErrorHandler: (() => void) | undefined; +const mockResizeObserve = jest.fn(); +const mockResizeDisconnect = jest.fn(); +let mockResizeCallback: ResizeObserverCallback | undefined; const mockTileLayerOn = jest.fn().mockImplementation((_event: string, handler: () => void) => { mockTileErrorHandler = handler; return { @@ -48,10 +51,20 @@ jest.mock('leaflet', () => ({ describe('AddressLocationMapComponent', () => { let component: AddressLocationMapComponent; let fixture: ComponentFixture; + let originalResizeObserver: typeof ResizeObserver | undefined; beforeEach(async () => { jest.clearAllMocks(); mockTileErrorHandler = undefined; + mockResizeCallback = undefined; + originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + mockResizeCallback = callback; + return { + observe: mockResizeObserve, + disconnect: mockResizeDisconnect + }; + }) as unknown as typeof ResizeObserver; await TestBed.configureTestingModule({ imports: [ @@ -64,6 +77,11 @@ describe('AddressLocationMapComponent', () => { component = fixture.componentInstance; }); + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver as typeof ResizeObserver; + jest.useRealTimers(); + }); + it('should initialize a map for valid coordinates', () => { component.latitude = 12.9716; component.longitude = 77.5946; @@ -81,6 +99,17 @@ describe('AddressLocationMapComponent', () => { ); }); + it('should refresh map sizing after the view and dialog animations settle', () => { + jest.useFakeTimers(); + component.latitude = 12.9716; + component.longitude = 77.5946; + + fixture.detectChanges(); + jest.runOnlyPendingTimers(); + + expect(mockMapInvalidateSize).toHaveBeenCalledTimes(2); + }); + it('should not initialize without valid coordinates', () => { component.latitude = null; component.longitude = undefined; @@ -99,6 +128,24 @@ describe('AddressLocationMapComponent', () => { component.ngOnDestroy(); expect(mockMapRemove).toHaveBeenCalledTimes(1); + expect(mockResizeDisconnect).toHaveBeenCalled(); + }); + + it('should refresh the map when the container size changes', () => { + jest.useFakeTimers(); + component.latitude = 12.9716; + component.longitude = 77.5946; + + fixture.detectChanges(); + + expect(mockResizeObserve).toHaveBeenCalledWith(fixture.nativeElement.querySelector('.map-container')); + + jest.runOnlyPendingTimers(); + mockMapInvalidateSize.mockClear(); + mockResizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver); + jest.runOnlyPendingTimers(); + + expect(mockMapInvalidateSize).toHaveBeenCalledTimes(2); }); it('should not initialize duplicate map instances', () => { @@ -117,6 +164,19 @@ describe('AddressLocationMapComponent', () => { ]); }); + it('should remove an existing map when coordinates become invalid', () => { + component.latitude = 12.9716; + component.longitude = 77.5946; + fixture.detectChanges(); + + fixture.componentRef.setInput('latitude', ''); + fixture.componentRef.setInput('longitude', 78); + fixture.detectChanges(); + + expect(mockMapRemove).toHaveBeenCalledTimes(1); + expect(fixture.nativeElement.querySelector('.address-location-map')).toBeNull(); + }); + it('should preserve zero values as valid coordinates', () => { component.latitude = 0; component.longitude = '0'; diff --git a/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.ts b/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.ts index 34dd0946c6..995c6620fc 100644 --- a/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.ts +++ b/src/app/clients/clients-view/address-tab/address-location-map/address-location-map.component.ts @@ -42,6 +42,7 @@ export class AddressLocationMapComponent implements AfterViewInit, OnChanges, On @ViewChild('mapContainer') set mapContainer(container: ElementRef | undefined) { this.mapContainerElement = container; + this.observeMapContainer(container); this.updateMap(); } @@ -49,6 +50,8 @@ export class AddressLocationMapComponent implements AfterViewInit, OnChanges, On private map: L.Map | null = null; private marker: L.Marker | null = null; private mapContainerElement?: ElementRef; + private resizeObserver: ResizeObserver | null = null; + private resizeTimeouts: ReturnType[] = []; tileLoadFailed = false; private readonly markerIcon = L.icon({ @@ -83,12 +86,22 @@ export class AddressLocationMapComponent implements AfterViewInit, OnChanges, On ngOnDestroy(): void { this.destroyMap(); + this.disconnectResizeObserver(); } get hasValidCoordinates(): boolean { return this.getCoordinatePair() !== null; } + refresh(): void { + if (!this.map) { + this.updateMap(); + return; + } + + this.scheduleResize(); + } + private updateMap(): void { const coordinates = this.getCoordinatePair(); if (!coordinates) { @@ -117,7 +130,33 @@ export class AddressLocationMapComponent implements AfterViewInit, OnChanges, On this.marker?.setLatLng(coordinates); } - setTimeout(() => this.map?.invalidateSize(), 0); + this.refresh(); + } + + private observeMapContainer(container: ElementRef | undefined): void { + this.disconnectResizeObserver(); + + if (!container || typeof ResizeObserver === 'undefined') { + return; + } + + this.resizeObserver = new ResizeObserver(() => this.refresh()); + this.resizeObserver.observe(container.nativeElement); + } + + private scheduleResize(): void { + this.clearResizeTimeouts(); + + [ + 0, + 250 + ].forEach((delay) => { + const resizeTimeout = setTimeout(() => { + this.map?.invalidateSize(); + this.resizeTimeouts = this.resizeTimeouts.filter((timeout) => timeout !== resizeTimeout); + }, delay); + this.resizeTimeouts.push(resizeTimeout); + }); } private getCoordinatePair(): L.LatLngTuple | null { @@ -125,9 +164,20 @@ export class AddressLocationMapComponent implements AfterViewInit, OnChanges, On } private destroyMap(): void { + this.clearResizeTimeouts(); this.marker = null; this.map?.remove(); this.map = null; this.tileLoadFailed = false; } + + private clearResizeTimeouts(): void { + this.resizeTimeouts.forEach((resizeTimeout) => clearTimeout(resizeTimeout)); + this.resizeTimeouts = []; + } + + private disconnectResizeObserver(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + } } diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.html b/src/app/clients/clients-view/address-tab/address-tab.component.html index 8a63de6367..aae4b32b1a 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.html +++ b/src/app/clients/clients-view/address-tab/address-tab.component.html @@ -20,7 +20,11 @@

{{ 'labels.heading.Address' | translate }}

@for (address of clientAddressData; track address.addressId; let i = $index) { - + {{ address.addressType }} @@ -92,7 +96,8 @@

{{ 'labels.heading.Address' | translate }}

clientAddressLocationEnabled && isFieldEnabled('latitude') && isFieldEnabled('longitude') && - hasValidCoordinatePair(address.latitude, address.longitude) + hasValidCoordinatePair(address.latitude, address.longitude) && + isAddressMapVisible(address) ) { } @@ -100,3 +105,15 @@

{{ 'labels.heading.Address' | translate }}

}
+ + + @if ( + clientAddressLocationEnabled && + isFieldEnabled('latitude') && + isFieldEnabled('longitude') && + form?.get('latitude') && + form?.get('longitude') + ) { + + } + diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.spec.ts b/src/app/clients/clients-view/address-tab/address-tab.component.spec.ts index 0f0be63c98..8e0b620b5a 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.spec.ts +++ b/src/app/clients/clients-view/address-tab/address-tab.component.spec.ts @@ -16,6 +16,8 @@ import * as L from 'leaflet'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import * as solidIcons from '@fortawesome/free-solid-svg-icons'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { MatExpansionPanel } from '@angular/material/expansion'; import { AddressTabComponent } from './address-tab.component'; import { ClientsService } from '../../clients.service'; @@ -153,6 +155,18 @@ describe('AddressTabComponent', () => { formGroupService = TestBed.inject(FormGroupService); }); + function expandFirstAddressPanel() { + const panel = fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance as MatExpansionPanel; + panel.afterExpand.emit(); + fixture.detectChanges(); + } + + function collapseFirstAddressPanel() { + const panel = fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance as MatExpansionPanel; + panel.afterCollapse.emit(); + fixture.detectChanges(); + } + it('should show latitude and longitude fields when enabled', () => { const formFields = component.getAddressFormFields('add'); @@ -216,6 +230,18 @@ describe('AddressTabComponent', () => { ); }); + it('should pass the location map preview template to the add dialog', () => { + fixture.detectChanges(); + dialog.open.mockReturnValue({ + afterClosed: () => of({}) + } as any); + + component.addAddress(); + + const dialogConfig = dialog.open.mock.calls[0][1] as { data: any }; + expect(dialogConfig.data.contentTemplate).toBe(component.addressLocationMapDialogTemplate); + }); + it('should include latitude and longitude in the edit payload', () => { dialog.open.mockReturnValue({ afterClosed: () => @@ -333,9 +359,24 @@ describe('AddressTabComponent', () => { expect(fixture.nativeElement.textContent).toContain('12.9716'); expect(fixture.nativeElement.textContent).toContain('77.5946'); + expect(L.map).not.toHaveBeenCalled(); + + expandFirstAddressPanel(); + expect(L.map).toHaveBeenCalledTimes(1); }); + it('should recreate the map cleanly when the address panel is reopened', () => { + fixture.detectChanges(); + + expandFirstAddressPanel(); + collapseFirstAddressPanel(); + expandFirstAddressPanel(); + + expect(L.map).toHaveBeenCalledTimes(2); + expect(mockMapRemove).toHaveBeenCalledTimes(1); + }); + it('should not render the map when coordinates are unavailable', () => { component.clientAddressData = [ { @@ -378,6 +419,8 @@ describe('AddressTabComponent', () => { fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('0'); + expandFirstAddressPanel(); + expect(fixture.nativeElement.querySelector('mifosx-address-location-map')).not.toBeNull(); expect(L.map).toHaveBeenCalledTimes(1); }); diff --git a/src/app/clients/clients-view/address-tab/address-tab.component.ts b/src/app/clients/clients-view/address-tab/address-tab.component.ts index 165e208822..c04a1220c2 100644 --- a/src/app/clients/clients-view/address-tab/address-tab.component.ts +++ b/src/app/clients/clients-view/address-tab/address-tab.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, TemplateRef, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { FormGroup } from '@angular/forms'; @@ -72,6 +72,9 @@ export class AddressTabComponent { readonly hasCoordinateValue = hasCoordinateValue; readonly hasValidCoordinatePair = hasValidCoordinatePair; + @ViewChild('addressLocationMapDialogTemplate') + addressLocationMapDialogTemplate?: TemplateRef<{ form: FormGroup }>; + get clientAddressLocationEnabled(): boolean { return environment.enableClientAddressLocation; } @@ -91,6 +94,7 @@ export class AddressTabComponent { clientAddressTemplate: any; /** Client Id */ clientId: string; + expandedAddressMapIds = new Set(); /** * @param {ActivatedRoute} route Activated Route @@ -120,7 +124,8 @@ export class AddressTabComponent { this.translateService.instant('labels.catalogs.Client') + ' ' + this.translateService.instant('labels.heading.Address'), - formfields: this.getAddressFormFields('add') + formfields: this.getAddressFormFields('add'), + contentTemplate: this.addressLocationMapDialogTemplate }; const addAddressDialogRef = this.dialog.open(FormDialogComponent, { data }); this.setupPostalCodeLookup(addAddressDialogRef); @@ -156,6 +161,7 @@ export class AddressTabComponent { ' ' + this.translateService.instant('labels.heading.Address'), formfields: this.getAddressFormFields('edit', address), + contentTemplate: this.addressLocationMapDialogTemplate, layout: { addButtonText: 'Edit' } }; const editAddressDialogRef = this.dialog.open(FormDialogComponent, { data }); @@ -331,6 +337,18 @@ export class AddressTabComponent { return this.clientAddressFieldConfig.find((fieldObj: any) => fieldObj.field === fieldName)?.isEnabled; } + showAddressMap(address: any): void { + this.expandedAddressMapIds.add(address.addressId); + } + + hideAddressMap(address: any): void { + this.expandedAddressMapIds.delete(address.addressId); + } + + isAddressMapVisible(address: any): boolean { + return this.expandedAddressMapIds.has(address.addressId); + } + /** * Find Pipe doesn't work with accordian * @param {any} fieldName Field Name diff --git a/src/app/clients/clients-view/audit-trail/audit-trail.component.html b/src/app/clients/clients-view/audit-trail/audit-trail.component.html new file mode 100644 index 0000000000..be36906ae2 --- /dev/null +++ b/src/app/clients/clients-view/audit-trail/audit-trail.component.html @@ -0,0 +1,140 @@ + + + + +
+ + + +
+
+ history + {{ 'labels.cbild.auditTrail.title' | translate }} +
+ + {{ totalElements }} {{ 'labels.cbild.auditTrail.totalEntries' | translate }} + +
+
+
+ + + + +

+ filter_list + {{ 'labels.cbild.auditTrail.filterTitle' | translate }} +

+ +
+ + {{ 'labels.cbild.auditTrail.startDate' | translate }} + + + + + {{ 'labels.cbild.auditTrail.endDate' | translate }} + + + + + + +
+
+
+ + + @if (isLoading) { + + } + + + @if (!isLoading && entries.length === 0) { + + + info_outline +

{{ 'labels.cbild.auditTrail.noEntries' | translate }}

+
+
+ } + + @if (!isLoading && entries.length > 0) { + + + @for (entry of entries; track entry.id) { +
+
+
+ #{{ entry.id }} + {{ entry.action }} + {{ entry.entityType }} +
+ + {{ entry.result }} + +
+ + + +
+
+ {{ 'labels.cbild.auditTrail.performedBy' | translate }} + {{ entry.performedBy }} +
+
+ {{ 'labels.cbild.auditTrail.createdAt' | translate }} + {{ entry.createdAt | dateFormat }} +
+
+ {{ 'labels.cbild.auditTrail.duration' | translate }} + {{ entry.durationMs }} ms +
+
+ {{ 'labels.cbild.auditTrail.requestId' | translate }} + {{ entry.requestId }} +
+
+
+ + @if (!$last) { + + } + } +
+
+ + + + + } +
diff --git a/src/app/clients/clients-view/audit-trail/audit-trail.component.scss b/src/app/clients/clients-view/audit-trail/audit-trail.component.scss new file mode 100644 index 0000000000..02e9c6fad0 --- /dev/null +++ b/src/app/clients/clients-view/audit-trail/audit-trail.component.scss @@ -0,0 +1,167 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.cbild-audit-trail { + padding: 16px; +} + +.cbild-header-card { + margin-bottom: 16px; +} + +.cbild-header-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.cbild-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 600; +} + +.cbild-total-badge { + font-size: 12px; + color: var(--mdc-theme-text-secondary-on-background, rgb(0 0 0 / 54%)); + background: var(--cbild-card-bg, #f8fafc); + border: 1px solid var(--cbild-card-border, #e2e8f0); + padding: 4px 10px; + border-radius: 12px; +} + +.cbild-filter-card { + margin-bottom: 16px; +} + +.cbild-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + margin-bottom: 12px; +} + +.cbild-filter-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + flex-wrap: wrap; +} + +.cbild-empty-card { + margin-bottom: 16px; + + mat-card-content { + display: flex; + align-items: center; + gap: 8px; + color: var(--mdc-theme-text-secondary-on-background, rgb(0 0 0 / 54%)); + } +} + +.cbild-entries-card { + margin-bottom: 16px; +} + +.cbild-audit-entry { + padding: 12px 0; +} + +.cbild-entry-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.cbild-entry-left { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cbild-entry-id { + font-size: 11px; + color: var(--mdc-theme-text-secondary-on-background, rgb(0 0 0 / 54%)); + font-weight: 500; +} + +.cbild-entry-action { + font-size: 13px; + font-weight: 600; + font-family: monospace; +} + +.cbild-entry-entity { + font-size: 11px; + color: var(--mdc-theme-text-secondary-on-background, rgb(0 0 0 / 54%)); + background: var(--cbild-card-bg, #f8fafc); + padding: 2px 6px; + border-radius: 4px; +} + +.cbild-entry-divider { + margin: 8px 0; +} + +.cbild-between-entries { + margin: 4px 0; +} + +.cbild-entry-details { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cbild-detail-row { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.cbild-label { + font-size: 12px; + color: var(--mdc-theme-text-secondary-on-background, rgb(0 0 0 / 54%)); + min-width: 120px; +} + +.cbild-value { + font-size: 13px; + font-weight: 500; +} + +.cbild-request-id { + font-family: monospace; + font-size: 11px; + color: var(--mdc-theme-text-secondary-on-background, rgb(0 0 0 / 54%)); +} + +.cbild-result-badge { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.cbild-result-success { + background: var(--cbild-status-accepted-bg, #dcfce7); + color: var(--cbild-status-accepted-fg, #16a34a); +} + +.cbild-result-failure { + background: var(--cbild-status-open-bg, #fee2e2); + color: var(--cbild-status-open-fg, #dc2626); +} diff --git a/src/app/clients/clients-view/audit-trail/audit-trail.component.ts b/src/app/clients/clients-view/audit-trail/audit-trail.component.ts new file mode 100644 index 0000000000..8342ec4cf4 --- /dev/null +++ b/src/app/clients/clients-view/audit-trail/audit-trail.component.ts @@ -0,0 +1,162 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, OnDestroy, inject, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subject } from 'rxjs'; +import { takeUntil, switchMap } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; +import { FormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { MatDivider } from '@angular/material/divider'; +import { MatProgressBar } from '@angular/material/progress-bar'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { TranslateService } from '@ngx-translate/core'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; +import { CreditBureauService } from 'app/credit-bureau/credit-bureau.service'; +import { AuditEntryDTO } from 'app/credit-bureau/credit-bureau.models'; + +/** + * CB-ILD Audit Trail — MX-380 Tab 5. + * Compliance only. Paginated. Date range filter. + * Route: /clients/{id}/audit-trail + * Role: COMPLIANCE only — enforced by @PreAuthorize on backend. + * @author Satyam Mishra — MSOC 2026 + */ +@Component({ + selector: 'mifosx-audit-trail', + templateUrl: './audit-trail.component.html', + styleUrls: ['./audit-trail.component.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIcon, + MatDivider, + MatProgressBar, + MatPaginator, + FormsModule + ] +}) +export class AuditTrailComponent implements OnInit, OnDestroy { + private route = inject(ActivatedRoute); + private creditBureauService = inject(CreditBureauService); + private alertService = inject(AlertService); + private cdr = inject(ChangeDetectorRef); + private translateService = inject(TranslateService); + + clientId: number; + + entries: AuditEntryDTO[] = []; + totalElements = 0; + pageIndex = 0; + pageSize = 10; + + isLoading = false; + + startDate = ''; + endDate = ''; + + private destroy$ = new Subject(); + private load$ = new Subject(); + + constructor() { + const rawId = this.route.parent?.snapshot.params['clientId']; + this.clientId = rawId ? +rawId : 0; + } + + ngOnInit(): void { + this.initLoadPipeline(); + this.load$.next(); + } + + private initLoadPipeline(): void { + this.load$ + .pipe( + switchMap(() => { + this.isLoading = true; + this.cdr.markForCheck(); + + const params: { page: number; size: number; startDate?: string; endDate?: string } = { + page: this.pageIndex, + size: this.pageSize + }; + + if (this.startDate && this.endDate) { + params.startDate = this.startDate; + params.endDate = this.endDate; + } + + return this.creditBureauService.getAuditTrail(this.clientId, params); + }), + takeUntil(this.destroy$) + ) + .subscribe({ + next: (data) => { + this.entries = data?._embedded?.auditEntryDTOList ?? []; + this.totalElements = data?.page?.totalElements ?? 0; + this.isLoading = false; + this.cdr.markForCheck(); + }, + error: (err: HttpErrorResponse) => { + this.isLoading = false; + this.alertService.alert({ type: 'error', message: this.getErrorMessage(err) }); + this.cdr.markForCheck(); + } + }); + } + + applyFilter(): void { + if (this.startDate && this.endDate && this.startDate >= this.endDate) { + this.alertService.alert({ + type: 'error', + message: this.translateService.instant('labels.cbild.auditTrail.invalidDateRange') + }); + return; + } + this.pageIndex = 0; + this.load$.next(); + } + + clearFilter(): void { + this.startDate = ''; + this.endDate = ''; + this.pageIndex = 0; + this.load$.next(); + } + + onPageChange(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.load$.next(); + } + + getResultClass(result: string): string { + return result === 'SUCCESS' ? 'cbild-result-success' : 'cbild-result-failure'; + } + + private getErrorMessage(err: HttpErrorResponse): string { + const t = (key: string) => this.translateService.instant(key); + switch (err?.status) { + case 401: + return t('labels.cbild.errors.unauthorized'); + case 403: + return t('labels.cbild.errors.forbidden'); + case 503: + return t('labels.cbild.errors.serviceUnavailable'); + default: + return err?.error?.message || t('labels.cbild.errors.unexpected'); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index 698ef23cba..3b62aae419 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -508,6 +508,26 @@ > {{ 'labels.inputs.Credit Profile' | translate }} + + {{ 'labels.inputs.Dispute Management' | translate }} + + @if (isCbildCompliance) { + + {{ 'labels.inputs.Audit Trail' | translate }} + + } } @for (clientDatatable of clientDatatables; track clientDatatable) { 0) { const file = $event.target.files[0]; + this.uploadDocumentForm.get('fileName').setValue(file.name); this.uploadDocumentForm.get('file').setValue(file); - if (!this.uploadDocumentForm.get('fileName').value) { - this.uploadDocumentForm.get('fileName').setValue(file.name); - } } } } diff --git a/src/app/clients/clients-view/dispute-management/dispute-management.component.html b/src/app/clients/clients-view/dispute-management/dispute-management.component.html new file mode 100644 index 0000000000..a13011cafc --- /dev/null +++ b/src/app/clients/clients-view/dispute-management/dispute-management.component.html @@ -0,0 +1,188 @@ + + + + +
+ + + + + {{ 'labels.cbild.dashboard.role' | translate }} + + @for (role of roles; track role.value) { + {{ role.label }} + } + + + + + + + + +

+ report_problem + {{ 'labels.cbild.dispute.raiseTitle' | translate }} +

+ +
+ + {{ 'labels.cbild.dispute.submissionRecordId' | translate }} + + @if (raiseForm.get('submissionRecordId')?.invalid && raiseForm.get('submissionRecordId')?.touched) { + {{ 'labels.cbild.dispute.submissionIdRequired' | translate }} + } + + + + {{ 'labels.cbild.dispute.disputeDetails' | translate }} + + @if (raiseForm.get('disputeDetails')?.invalid && raiseForm.get('disputeDetails')?.touched) { + {{ 'labels.cbild.dispute.detailsRequired' | translate }} + } + + + +
+ + + @if (raisedDispute) { +
+ +
+ } +
+
+ + + + +

+ search + {{ 'labels.cbild.dispute.lookupTitle' | translate }} +

+ +
+ + {{ 'labels.cbild.dispute.disputeId' | translate }} + + + +
+ + @if (lookedUpDispute) { + + } +
+
+
+ + + +
+
+ {{ 'labels.cbild.dispute.id' | translate }} #{{ dispute.id }} + {{ dispute.status }} +
+ + + +
+
+ {{ 'labels.cbild.dispute.submissionRef' | translate }} + {{ dispute.submissionRecordId }} +
+
+ {{ 'labels.cbild.dispute.raisedBy' | translate }} + {{ dispute.raisedBy }} +
+
+ {{ 'labels.cbild.dispute.openedAt' | translate }} + {{ dispute.openedAt | dateFormat }} +
+
+ {{ 'labels.cbild.dispute.details' | translate }} + {{ dispute.disputeDetails }} +
+ @if (dispute.resolvedAt) { +
+ {{ 'labels.cbild.dispute.resolvedAt' | translate }} + {{ dispute.resolvedAt | dateFormat }} +
+ } + @if (dispute.resolutionNotes) { +
+ {{ 'labels.cbild.dispute.resolutionNotes' | translate }} + {{ dispute.resolutionNotes }} +
+ } +
+ {{ 'labels.cbild.dispute.expiry' | translate }} + {{ dispute.expiryDate }} +
+
+ + + @if (dispute.status !== 'RESOLVED') { +
+ @if (canMoveToUnderReview(dispute)) { + + } + + @if (canResolve(dispute)) { + + {{ 'labels.cbild.dispute.resolutionNotes' | translate }} + + + + } +
+ } +
+
diff --git a/src/app/clients/clients-view/dispute-management/dispute-management.component.scss b/src/app/clients/clients-view/dispute-management/dispute-management.component.scss new file mode 100644 index 0000000000..0cad65f256 --- /dev/null +++ b/src/app/clients/clients-view/dispute-management/dispute-management.component.scss @@ -0,0 +1,138 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.cbild-dispute-management { + padding: 16px; +} + +.cbild-role-card { + margin-bottom: 16px; + + mat-card-content { + display: flex; + align-items: center; + gap: 16px; + } + + mat-form-field { + width: 240px; + } +} + +.cbild-section-card { + margin-bottom: 16px; +} + +.cbild-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + margin-bottom: 12px; +} + +.cbild-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; +} + +.cbild-full-width { + width: 100%; +} + +.cbild-lookup-row { + display: flex; + align-items: center; + gap: 16px; + margin-top: 16px; + flex-wrap: wrap; +} + +.cbild-dispute-result { + margin-top: 16px; +} + +.cbild-dispute-card { + border: 1px solid var(--cbild-card-border, #e2e8f0); + border-radius: 8px; + padding: 16px; + margin-top: 16px; + background: var(--cbild-card-bg, #f8fafc); +} + +.cbild-dispute-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.cbild-dispute-id { + font-size: 14px; + font-weight: 600; +} + +.cbild-divider { + margin: 12px 0; +} + +.cbild-dispute-details { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cbild-detail-row { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.cbild-label { + font-size: 12px; + color: var(--mdc-theme-text-secondary-on-background, rgb(0 0 0 / 54%)); + min-width: 140px; +} + +.cbild-value { + font-size: 13px; + font-weight: 500; +} + +.cbild-actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.cbild-status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.cbild-status-open { + background: var(--cbild-status-open-bg, #dbeafe); + color: var(--cbild-status-open-fg, #1d4ed8); +} + +.cbild-status-pending { + background: var(--cbild-status-pending-bg, #fef9c3); + color: var(--cbild-status-pending-fg, #d97706); +} + +.cbild-status-accepted { + background: var(--cbild-status-accepted-bg, #dcfce7); + color: var(--cbild-status-accepted-fg, #16a34a); +} diff --git a/src/app/clients/clients-view/dispute-management/dispute-management.component.ts b/src/app/clients/clients-view/dispute-management/dispute-management.component.ts new file mode 100644 index 0000000000..a28d2dac4f --- /dev/null +++ b/src/app/clients/clients-view/dispute-management/dispute-management.component.ts @@ -0,0 +1,242 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, OnDestroy, inject, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { MatDivider } from '@angular/material/divider'; +import { FormsModule } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { AlertService } from 'app/core/alert/alert.service'; +import { CreditBureauService } from 'app/credit-bureau/credit-bureau.service'; +import { DisputeCase, CbildRole, CBILD_ROLE_LABELS } from 'app/credit-bureau/credit-bureau.models'; + +/** + * CB-ILD Dispute Management — MX-279 Tab 4. + * Raise disputes and manage OPEN→UNDER_REVIEW→RESOLVED workflow. + * Route: /clients/{id}/dispute-management + * Roles: All roles can raise + move to UNDER_REVIEW. COMPLIANCE only can RESOLVE. + * @author Satyam Mishra — MSOC 2026 + */ +@Component({ + selector: 'mifosx-dispute-management', + templateUrl: './dispute-management.component.html', + styleUrls: ['./dispute-management.component.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatIcon, + MatDivider, + FormsModule + ] +}) +export class DisputeManagementComponent implements OnInit, OnDestroy { + private route = inject(ActivatedRoute); + private creditBureauService = inject(CreditBureauService); + private alertService = inject(AlertService); + private cdr = inject(ChangeDetectorRef); + private translateService = inject(TranslateService); + private fb = inject(FormBuilder); + + clientId: number; + selectedRole: CbildRole = 'CREDIT_ANALYST'; + + roles: { value: CbildRole; label: string }[] = [ + { value: 'KYC_OFFICER', label: CBILD_ROLE_LABELS.KYC_OFFICER }, + { value: 'CREDIT_ANALYST', label: CBILD_ROLE_LABELS.CREDIT_ANALYST }, + { value: 'COMPLIANCE', label: CBILD_ROLE_LABELS.COMPLIANCE } + ]; + + // Raise dispute form + raiseForm: FormGroup; + isRaising = false; + raisedDispute: DisputeCase | null = null; + + // Look up dispute + lookupDisputeId: number | null = null; + isLooking = false; + lookedUpDispute: DisputeCase | null = null; + + // Update status + isUpdating = false; + resolutionNotesMap = new Map(); + + get isCompliance(): boolean { + return this.selectedRole === 'COMPLIANCE'; + } + + private destroy$ = new Subject(); + + constructor() { + const rawId = this.route.parent?.snapshot.params['clientId']; + this.clientId = rawId ? +rawId : 0; + } + + ngOnInit(): void { + const saved = this.creditBureauService.getRole(); + this.selectedRole = saved; + this.creditBureauService.setRole(this.selectedRole); + + this.raiseForm = this.fb.group({ + submissionRecordId: [ + null, + [ + Validators.required, + Validators.min(1) + ] + ], + disputeDetails: [ + '', + [ + Validators.required, + Validators.minLength(10) + ] + ] + }); + } + + onRoleChange(role: CbildRole): void { + this.selectedRole = role; + this.creditBureauService.setRole(role); + this.raisedDispute = null; + this.lookedUpDispute = null; + this.cdr.markForCheck(); + } + + raiseDispute(): void { + if (this.raiseForm.invalid) { + this.raiseForm.markAllAsTouched(); + return; + } + this.isRaising = true; + this.cdr.markForCheck(); + + const { submissionRecordId, disputeDetails } = this.raiseForm.value; + + this.creditBureauService + .createDispute(+submissionRecordId, disputeDetails) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (dispute: DisputeCase) => { + this.raisedDispute = dispute; + this.isRaising = false; + this.raiseForm.reset(); + this.alertService.alert({ + type: 'success', + message: this.translateService.instant('labels.cbild.dispute.raised', { id: dispute.id }) + }); + this.cdr.markForCheck(); + }, + error: (err: HttpErrorResponse) => { + this.isRaising = false; + this.alertService.alert({ type: 'error', message: this.getErrorMessage(err) }); + this.cdr.markForCheck(); + } + }); + } + + lookupDispute(): void { + if (!this.lookupDisputeId) return; + this.isLooking = true; + this.lookedUpDispute = null; + this.cdr.markForCheck(); + + this.creditBureauService + .getDispute(this.lookupDisputeId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (dispute: DisputeCase) => { + this.lookedUpDispute = dispute; + this.isLooking = false; + this.cdr.markForCheck(); + }, + error: (err: HttpErrorResponse) => { + this.isLooking = false; + this.alertService.alert({ type: 'error', message: this.getErrorMessage(err) }); + this.cdr.markForCheck(); + } + }); + } + + updateStatus(dispute: DisputeCase, newStatus: string): void { + this.isUpdating = true; + this.cdr.markForCheck(); + + const notes = newStatus === 'RESOLVED' ? this.resolutionNotesMap.get(dispute.id) || '' : undefined; + + this.creditBureauService + .updateDisputeStatus(dispute.id, newStatus, notes) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (updated: DisputeCase) => { + if (this.raisedDispute?.id === dispute.id) this.raisedDispute = updated; + if (this.lookedUpDispute?.id === dispute.id) this.lookedUpDispute = updated; + this.isUpdating = false; + this.resolutionNotesMap.delete(dispute.id); + this.alertService.alert({ + type: 'success', + message: this.translateService.instant('labels.cbild.dispute.statusUpdated', { status: newStatus }) + }); + this.cdr.markForCheck(); + }, + error: (err: HttpErrorResponse) => { + this.isUpdating = false; + this.alertService.alert({ type: 'error', message: this.getErrorMessage(err) }); + this.cdr.markForCheck(); + } + }); + } + + getStatusClass(status: string): string { + switch (status) { + case 'OPEN': + return 'cbild-status-open'; + case 'UNDER_REVIEW': + return 'cbild-status-pending'; + case 'RESOLVED': + return 'cbild-status-accepted'; + default: + return ''; + } + } + + canMoveToUnderReview(dispute: DisputeCase): boolean { + return dispute.status === 'OPEN'; + } + + canResolve(dispute: DisputeCase): boolean { + return this.isCompliance && dispute.status === 'UNDER_REVIEW'; + } + + private getErrorMessage(err: HttpErrorResponse): string { + const t = (key: string) => this.translateService.instant(key); + switch (err?.status) { + case 400: + return err?.error?.message || t('labels.cbild.errors.badRequest'); + case 401: + return t('labels.cbild.errors.unauthorized'); + case 403: + return t('labels.cbild.errors.forbidden'); + case 503: + return t('labels.cbild.errors.serviceUnavailable'); + default: + return err?.error?.message || t('labels.cbild.errors.unexpected'); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/clients/clients.component.html b/src/app/clients/clients.component.html index b51ccc3021..88c43abe2a 100644 --- a/src/app/clients/clients.component.html +++ b/src/app/clients/clients.component.html @@ -68,11 +68,11 @@ @if (isLoading) { - + } -
+
diff --git a/src/app/clients/clients.component.ts b/src/app/clients/clients.component.ts index d0ec2927ba..455552ee79 100644 --- a/src/app/clients/clients.component.ts +++ b/src/app/clients/clients.component.ts @@ -18,7 +18,6 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatTableDataSource } from '@angular/material/table'; -import { MatProgressBar } from '@angular/material/progress-bar'; /** rxjs Imports */ import { Subject, Subscription } from 'rxjs'; @@ -33,6 +32,7 @@ import { LegalFormId } from './models/legal-form.enum'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { DateFormatPipe } from '../pipes/date-format.pipe'; import { nameInitials } from 'app/core/utils/name-initials'; +import { PageLoaderComponent } from '../shared/page-loader/page-loader.component'; export const DEBOUNCE_MS = 500; @@ -44,7 +44,7 @@ type Severity = 'active' | 'pending' | 'closed' | 'rejected' | 'neutral'; styleUrls: ['./clients.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - MatProgressBar, + PageLoaderComponent, AccountNumberComponent, ExternalIdentifierComponent, DateFormatPipe diff --git a/src/app/clients/clients.module.ts b/src/app/clients/clients.module.ts index 381dd11ed2..dbfdf6e29d 100644 --- a/src/app/clients/clients.module.ts +++ b/src/app/clients/clients.module.ts @@ -27,6 +27,8 @@ import { UploadDocumentDialogComponent } from './clients-view/custom-dialogs/upl import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component'; import { BureauReadinessComponent } from './clients-view/bureau-readiness/bureau-readiness.component'; import { CreditProfileComponent } from './clients-view/credit-profile/credit-profile.component'; +import { DisputeManagementComponent } from './clients-view/dispute-management/dispute-management.component'; +import { AuditTrailComponent } from './clients-view/audit-trail/audit-trail.component'; import { EditNotesDialogComponent } from './clients-view/custom-dialogs/edit-notes-dialog/edit-notes-dialog.component'; import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component'; import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component'; @@ -91,6 +93,8 @@ import { ClientDatatableStepComponent } from './client-stepper/client-datatable- NotesTabComponent, BureauReadinessComponent, CreditProfileComponent, + DisputeManagementComponent, + AuditTrailComponent, EditNotesDialogComponent, DocumentsTabComponent, DatatableTabComponent, diff --git a/src/app/clients/clients.service.spec.ts b/src/app/clients/clients.service.spec.ts index 5556907ff0..8f51bcd3b2 100644 --- a/src/app/clients/clients.service.spec.ts +++ b/src/app/clients/clients.service.spec.ts @@ -243,6 +243,42 @@ describe('ClientsService', () => { }); }); + describe('Client Document Requests', () => { + it('should download client document attachment for a valid document id', async () => { + const mockDocument = new Blob(['profile-image'], { type: 'image/png' }); + const resultPromise = firstValueFrom(service.downloadClientDocument('123', '45')); + + const req = httpMock.expectOne((r) => r.url === '/clients/123/documents/45/attachment' && r.method === 'GET'); + expect(req.request.responseType).toBe('blob'); + + req.flush(mockDocument); + const result = await resultPromise; + expect(result).toBe(mockDocument); + }); + + it('should not request client document attachment for invalid document ids', async () => { + const invalidDocumentIds = [ + '-1', + null, + undefined, + Number.NaN, + Number.POSITIVE_INFINITY, + 'Infinity', + '0', + 0, + '-5' + ]; + + for (const documentId of invalidDocumentIds) { + await expect(firstValueFrom(service.downloadClientDocument('123', documentId))).rejects.toThrow( + 'Invalid client document id.' + ); + } + + httpMock.expectNone((r) => r.url.includes('/clients/123/documents/')); + }); + }); + describe('Client Family Members', () => { it('should fetch family members (GET /clients/:id/familymembers)', async () => { const mockMembers = [ diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 3a512d635d..18d258a41d 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -29,6 +29,15 @@ export class ClientsService { /** Separate HttpClient that bypasses interceptors (for external API calls) */ private externalHttp = new HttpClient(this.httpBackend); + private isValidDocumentId(documentId: string | number | null | undefined): boolean { + const parsedDocumentId = Number(documentId); + return Number.isFinite(parsedDocumentId) && parsedDocumentId > 0; + } + + private invalidDocumentIdError(): Observable { + return throwError(() => new Error('Invalid client document id.')); + } + getFilteredClients( orderBy: string, sortOrder: string, @@ -227,7 +236,10 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/documents`, formData); } - getClientSignatureImage(clientId: string, documentId: string) { + getClientSignatureImage(clientId: string, documentId: string | number | null | undefined) { + if (!this.isValidDocumentId(documentId)) { + return this.invalidDocumentIdError(); + } return this.http.get(`/clients/${clientId}/documents/${documentId}/attachment`, { responseType: 'blob' }); } @@ -285,7 +297,10 @@ export class ClientsService { return this.http.get(`/clients/${clientId}/documents`); } - downloadClientDocument(parentEntityId: string, documentId: string) { + downloadClientDocument(parentEntityId: string, documentId: string | number | null | undefined) { + if (!this.isValidDocumentId(documentId)) { + return this.invalidDocumentIdError(); + } return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' }); } @@ -293,7 +308,10 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/documents`, documentData); } - deleteClientDocument(parentEntityId: string, documentId: string) { + deleteClientDocument(parentEntityId: string, documentId: string | number | null | undefined) { + if (!this.isValidDocumentId(documentId)) { + return this.invalidDocumentIdError(); + } return this.http.delete(`/clients/${parentEntityId}/documents/${documentId}`); } diff --git a/src/app/copilot/components/chat-area/chat-area.component.html b/src/app/copilot/components/chat-area/chat-area.component.html index a025a8c2a2..4e1ddbbd77 100644 --- a/src/app/copilot/components/chat-area/chat-area.component.html +++ b/src/app/copilot/components/chat-area/chat-area.component.html @@ -23,7 +23,7 @@

{{ 'copilot.welcomeHeading' | translate }}

- Mifos AI +
@@ -55,9 +55,35 @@

{{ 'copilot.welcomeHeading' | translate }}

+ +
+
+ +
+
+
+ + +
+
+
+
-
Mifos AI
+
+ +
diff --git a/src/app/copilot/components/chat-area/chat-area.component.ts b/src/app/copilot/components/chat-area/chat-area.component.ts index 8d60799e0a..88df4c54f9 100644 --- a/src/app/copilot/components/chat-area/chat-area.component.ts +++ b/src/app/copilot/components/chat-area/chat-area.component.ts @@ -6,13 +6,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { + AfterViewChecked, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, + inject +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import moment from 'moment'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; import { ChatMessage } from '../../core/models/chat-message.model'; +import { ActionCard } from '../../core/models/action-card.model'; import { MarkdownPipe } from '../../pipes/markdown.pipe'; import { ActionCardComponent } from '../action-card/action-card.component'; +import { ConfirmationCardComponent } from '../confirmation-card/confirmation-card.component'; import { QuickChipsComponent } from '../quick-chips/quick-chips.component'; /** Scrollable chat body: welcome state, message bubbles, cards, typing indicator. */ @@ -23,27 +38,76 @@ import { QuickChipsComponent } from '../quick-chips/quick-chips.component'; TranslateModule, MarkdownPipe, ActionCardComponent, + ConfirmationCardComponent, QuickChipsComponent ], templateUrl: './chat-area.component.html', styleUrls: ['./chat-area.component.scss'] }) -export class ChatAreaComponent { +export class ChatAreaComponent implements OnChanges, AfterViewChecked { @Input() messages: ChatMessage[] = []; @Input() isStreaming = false; @Input() greetingTime = ''; @Input() emptySuggestions: string[] = []; + /** Write action awaiting confirmation — rendered INSIDE the chat flow so it scrolls + * with the conversation and can never overlap other messages. */ + @Input() pendingCard: ActionCard | null = null; @Output() promptSelected = new EventEmitter(); @Output() cardAction = new EventEmitter(); @Output() cardRoute = new EventEmitter(); + @Output() confirmPending = new EventEmitter(); + @Output() cancelPending = new EventEmitter(); + + @ViewChild('messageContainer') private messageContainer?: ElementRef; + + /** How close to the bottom (px) still counts as "following the conversation". */ + private static readonly STICK_THRESHOLD_PX = 160; + private pendingScroll = false; + + /** + * Auto-follow new content: stick to the bottom while the user is already there + * (or has just sent a message), but never yank them down while they scrolled + * up to read history mid-stream. + */ + ngOnChanges(changes: SimpleChanges): void { + if (!changes['messages'] && !changes['isStreaming'] && !changes['pendingCard']) { + return; + } + if (changes['pendingCard'] && this.pendingCard) { + this.pendingScroll = true; // A new confirmation card always scrolls into view. + return; + } + const container = this.messageContainer?.nativeElement; + const lastIsUser = this.messages[this.messages.length - 1]?.role === 'user'; + const nearBottom = !container + ? true + : container.scrollHeight - container.scrollTop - container.clientHeight < ChatAreaComponent.STICK_THRESHOLD_PX; + this.pendingScroll = nearBottom || lastIsUser; + } + + ngAfterViewChecked(): void { + if (this.pendingScroll && this.messageContainer) { + const container = this.messageContainer.nativeElement; + container.scrollTop = container.scrollHeight; + this.pendingScroll = false; + } + } trackByMessageId(_index: number, msg: ChatMessage): string { return msg.id; } - /** Relative time via moment (localizes with the active moment locale). */ + private readonly settingsService = inject(SettingsService); + private readonly dateUtils = inject(Dates); + + /** + * Relative time localized to the USER'S CHOSEN app language — resolved per + * call (instance locale), never trusting moment's global locale, which other + * pipes mutate as a side effect and may point at a different language. + */ relativeTime(timestamp: number): string { - return moment(timestamp).fromNow(); + const locale = this.dateUtils.getMomentLocale(this.settingsService.language); + return moment(timestamp).locale(locale).fromNow(); } } diff --git a/src/app/copilot/components/copilot-panel/copilot-panel.component.html b/src/app/copilot/components/copilot-panel/copilot-panel.component.html index f54e777b41..7e7f73ea79 100644 --- a/src/app/copilot/components/copilot-panel/copilot-panel.component.html +++ b/src/app/copilot/components/copilot-panel/copilot-panel.component.html @@ -35,9 +35,12 @@ [isStreaming]="isStreaming" [greetingTime]="greetingTime" [emptySuggestions]="emptySuggestions" + [pendingCard]="pendingCard" (promptSelected)="sendSuggestedPrompt($event)" (cardAction)="onActionClick($event)" (cardRoute)="onRouteClick($event)" + (confirmPending)="confirmPendingAction()" + (cancelPending)="cancelPendingAction()" > diff --git a/src/app/copilot/components/copilot-panel/copilot-panel.component.scss b/src/app/copilot/components/copilot-panel/copilot-panel.component.scss index 6dc44a344e..9d503f5f9b 100644 --- a/src/app/copilot/components/copilot-panel/copilot-panel.component.scss +++ b/src/app/copilot/components/copilot-panel/copilot-panel.component.scss @@ -364,7 +364,12 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); padding: ($u * 2) ($u * 2 + 4px); font-size: 14.5px; line-height: 1.7; - overflow-wrap: break-word; + + // 'anywhere' (unlike 'break-word') also shrinks min-content width, so an + // unbroken JSON/URL string can never push the bubble outside the panel. + overflow-wrap: anywhere; + min-width: 0; + max-width: 100%; letter-spacing: 0.01em; strong { @@ -393,6 +398,50 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); line-height: 1.6; } } + + // Markdown tables (e.g. client lists): real rows and columns, scrolling + // horizontally inside their own box when narrow. + .md-table-wrap { + max-width: 100%; + overflow-x: auto; + margin: $u 0; + border-radius: $r-sm; + } + + .md-table { + border-collapse: collapse; + width: 100%; + font-size: 13px; + + th { + text-align: left; + font-weight: 700; + padding: $u ($u * 2); + white-space: nowrap; + } + + td { + padding: $u ($u * 2); + vertical-align: top; + } + } + + // Fenced code blocks: scroll inside their own box, never widen the bubble. + .md-code { + margin: $u 0; + padding: $u ($u * 2); + border-radius: $r-sm; + max-width: 100%; + overflow-x: auto; + font-size: 12.5px; + line-height: 1.55; + + code { + font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + } } &__cursor { diff --git a/src/app/copilot/components/copilot-panel/copilot-panel.component.ts b/src/app/copilot/components/copilot-panel/copilot-panel.component.ts index fd1b949903..0ada3bb6f7 100644 --- a/src/app/copilot/components/copilot-panel/copilot-panel.component.ts +++ b/src/app/copilot/components/copilot-panel/copilot-panel.component.ts @@ -7,15 +7,23 @@ */ /** Angular Imports */ -import { Component, Input, ViewEncapsulation, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit, ViewEncapsulation, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { startWith } from 'rxjs/operators'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; /** Models */ import { ChatMessage, Conversation } from '../../core/models/chat-message.model'; +import { ActionCard } from '../../core/models/action-card.model'; +import { PendingAction } from '../../core/models/mcp-response.model'; /** Services */ +import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { CopilotFeatureService } from '../../services/copilot-feature.service'; +import { ChatService } from '../../services/chat.service'; +import { AiContextService } from '../../services/ai-context.service'; /** Child components */ import { CopilotHeaderComponent } from '../copilot-header/copilot-header.component'; @@ -26,16 +34,13 @@ import { InputBarComponent } from '../input-bar/input-bar.component'; export type CopilotTab = 'chat' | 'recent' | 'preferences' | 'help'; /** - * Container / shell for the Copilot. Owns panel state (open, active tab), - * the message list and conversations, and orchestrates send / stop / clear. + * Container / shell for the Copilot. Owns panel state (open, active tab) and + * delegates the conversation to ChatService: sanitize -> gateway SSE -> typed + * events, including the mandatory human confirmation before any write. * * Styling: this component carries the entire Copilot stylesheet with * ViewEncapsulation.None, scoped under the `.mifos-copilot` root class so it * also styles the child components nested in its template without leaking. - * - * NOTE: the MCP server is not wired yet - sendMessage() currently produces a - * mock assistant reply so the UI is demoable. Swap in ChatService once the - * endpoint contract is finalised. */ @Component({ selector: 'mifosx-copilot-panel', @@ -51,11 +56,20 @@ export type CopilotTab = 'chat' | 'recent' | 'preferences' | 'help'; styleUrls: ['./copilot-panel.component.scss'], encapsulation: ViewEncapsulation.None }) -export class CopilotPanelComponent { +export class CopilotPanelComponent implements OnInit { private readonly featureService = inject(CopilotFeatureService); private readonly translate = inject(TranslateService); + private readonly chatService = inject(ChatService); + private readonly contextService = inject(AiContextService); + private readonly authenticationService = inject(AuthenticationService); + private readonly router = inject(Router); - /** Master enable check (deployment + role + user preference). */ + /** + * Master enable check (deployment + role + user preference). Re-evaluated whenever + * the authentication state changes: the shell can create this panel before the + * credentials (and therefore the permissions) are available, and a once-only check + * would leave the panel hidden until a page reload. + */ isEnabled = this.featureService.shouldShowPanel(); /** Whether the full-page panel is shown. */ isOpen = false; @@ -66,10 +80,12 @@ export class CopilotPanelComponent { @Input() sidenavCollapsed = true; @Input() isHandset = false; - /** Conversation state. */ + /** Conversation state, mirrored from ChatService. */ messages: ChatMessage[] = []; conversations: Conversation[] = []; isStreaming = false; + /** Write action awaiting confirmation, rendered as a confirmation card. */ + pendingCard: ActionCard | null = null; /** Header context label, e.g. "Client: Rajesh Kumar". */ contextLabel: string | null = null; @@ -82,7 +98,42 @@ export class CopilotPanelComponent { 'copilot.suggestions.overdueLoans' ]; - private seq = 0; + // markForCheck() on every mirror update: this panel is created dynamically by the shell, + // and streaming callbacks arrive outside a template event — without it, an OnPush ancestor + // chain would never repaint the incoming tokens. + constructor() { + const cdr = inject(ChangeDetectorRef); + this.chatService.messages$.pipe(takeUntilDestroyed()).subscribe((messages) => { + this.messages = messages; + cdr.markForCheck(); + }); + this.chatService.conversations$.pipe(takeUntilDestroyed()).subscribe((conversations) => { + this.conversations = conversations; + cdr.markForCheck(); + }); + this.chatService.isStreaming$.pipe(takeUntilDestroyed()).subscribe((streaming) => { + this.isStreaming = streaming; + cdr.markForCheck(); + }); + this.chatService.pendingAction$.pipe(takeUntilDestroyed()).subscribe((pending) => { + this.pendingCard = pending ? this.toConfirmationCard(pending) : null; + cdr.markForCheck(); + }); + this.contextService.context$ + .pipe(startWith(this.contextService.getContextSnapshot()), takeUntilDestroyed()) + .subscribe((context) => { + this.contextLabel = context.clientName; + cdr.markForCheck(); + }); + this.authenticationService.isAuthenticated$.pipe(takeUntilDestroyed()).subscribe(() => { + this.isEnabled = this.featureService.shouldShowPanel(); + cdr.markForCheck(); + }); + } + + ngOnInit(): void { + this.chatService.loadHistory(); + } /** Returns the translation key for the time-of-day greeting. */ get greetingTime(): string { @@ -105,7 +156,7 @@ export class CopilotPanelComponent { } clearChat(): void { - this.messages = []; + this.chatService.clearChat(); this.activeTab = 'chat'; } @@ -116,11 +167,7 @@ export class CopilotPanelComponent { return; } this.activeTab = 'chat'; - this.messages = [ - ...this.messages, - { id: this.nextId(), role: 'user', content, timestamp: Date.now() } - ]; - this.respondMock(content); + this.chatService.sendMessage(content); } /** @@ -133,49 +180,64 @@ export class CopilotPanelComponent { } stopStreaming(): void { - this.isStreaming = false; + this.chatService.stopStreaming(); + } + + /** Officer confirmed the pending write — the gateway executes it now. */ + confirmPendingAction(): void { + this.chatService.decideAction('approve'); + } + + /** Officer rejected the pending write — nothing executes. */ + cancelPendingAction(): void { + this.chatService.decideAction('reject'); } - openConversation(conv: Conversation): void { - this.messages = conv.messages ?? []; + openConversation(conversation: Conversation): void { + this.chatService.openConversation(conversation); this.activeTab = 'chat'; } deleteConversation(event: Event, id: string): void { event.stopPropagation(); - this.conversations = this.conversations.filter((c) => c.id !== id); + this.chatService.deleteConversation(id); } - onActionClick(_action: string | undefined): void { - // TODO: route write actions through a confirmation dialog + MCP. + /** Card buttons carry a follow-up prompt; send it as a normal message. */ + onActionClick(action: string | undefined): void { + if (action) { + this.sendMessage(action); + } } - onRouteClick(_route: string | undefined): void { - // TODO: navigate via Angular Router. + /** + * Card buttons may deep-link into the app (e.g. "Open client profile"). The panel + * closes so the officer actually SEES the page they navigated to; the conversation + * is preserved and one click on the bubble brings it back. + */ + onRouteClick(route: string | undefined): void { + if (route) { + this.router.navigateByUrl(route); + this.isOpen = false; + } } - /** Placeholder reply so the panel is demoable before MCP is connected. */ - private respondMock(userText: string): void { - this.isStreaming = true; - const reply: ChatMessage = { - id: this.nextId(), - role: 'assistant', - content: `You asked: **${userText}**\n\nThis is a placeholder reply - the MCP server is not connected yet.`, - timestamp: Date.now(), - suggestedPrompts: [ - 'Show client portfolio', - 'View overdue loans' - ] + /** Flatten the pending action into the confirmation card the officer reviews. */ + private toConfirmationCard(pending: PendingAction): ActionCard { + const data: Record = {}; + for (const [ + key, + value + ] of Object.entries(pending.args ?? {})) { + data[key] = value != null && typeof value === 'object' ? JSON.stringify(value) : String(value ?? ''); + } + if (pending.idempotencyKey) { + data[this.translate.instant('copilot.confirm.reference')] = pending.idempotencyKey; + } + return { + type: 'confirmation', + title: pending.humanSummary || pending.tool, + data }; - this.messages = [ - ...this.messages, - reply - ]; - this.isStreaming = false; - } - - private nextId(): string { - this.seq += 1; - return `m-${this.seq}`; } } diff --git a/src/app/copilot/copilot-enabled.guard.ts b/src/app/copilot/copilot-enabled.guard.ts deleted file mode 100644 index d51fd0a9cf..0000000000 --- a/src/app/copilot/copilot-enabled.guard.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright since 2025 Mifos Initiative - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -import { inject } from '@angular/core'; -import { CanMatchFn } from '@angular/router'; -import { CopilotFeatureService } from './services/copilot-feature.service'; - -/** - * Route guard for the lazily-loaded Copilot feature. - * Blocks loading entirely when the deployment flag is off so disabled - * builds ship zero Copilot bytes. (CanMatch - CanLoad is deprecated.) - */ -export const copilotEnabledGuard: CanMatchFn = () => { - return inject(CopilotFeatureService).isEnabledForDeployment(); -}; diff --git a/src/app/copilot/copilot.component-theme.scss b/src/app/copilot/copilot.component-theme.scss index 223cb5c2f8..dca3adf01c 100644 --- a/src/app/copilot/copilot.component-theme.scss +++ b/src/app/copilot/copilot.component-theme.scss @@ -165,6 +165,30 @@ $copilot-warning: #f59e0b; background: rgba(if($is-dark, #fff, #000), 0.06); } + &__bubble .md-code { + background: rgba(if($is-dark, #fff, #000), 0.06); + border: 1px solid $border; + } + + &__bubble .md-table { + th { + background: rgba(if($is-dark, #fff, #000), 0.08); + border-bottom: 2px solid $border; + } + + td { + border-bottom: 1px solid $border; + } + + tr:last-child td { + border-bottom: none; + } + } + + &__bubble .md-table-wrap { + border: 1px solid $border; + } + &__avatar { background: $surface; border-color: $border; diff --git a/src/app/copilot/copilot.config.ts b/src/app/copilot/copilot.config.ts index 990f3258a4..63032f45e9 100644 --- a/src/app/copilot/copilot.config.ts +++ b/src/app/copilot/copilot.config.ts @@ -9,26 +9,36 @@ import { InjectionToken } from '@angular/core'; import { environment } from '../../environments/environment'; -/** Runtime configuration for the Copilot feature. */ +/** Runtime configuration for the Copilot feature (wire contract v1, ADR-001). */ export interface CopilotConfig { - /** Base URL of the MCP server, e.g. https://ai.mifos.community. */ + /** + * Base URL of the Copilot gateway, e.g. https://gateway.example.com. + * The literal value 'mock' (or empty) serves fixture responses so the UI + * works before a gateway is deployed. + */ mcpBaseUrl: string; - /** Per-request timeout in ms. */ - requestTimeoutMs: number; - /** Max retry attempts (exponential backoff). */ - maxRetries: number; + /** + * Time allowed until the FIRST streamed chunk arrives. LLM turns with tool + * calls routinely take tens of seconds before the first token, so this is + * deliberately long. Chat POSTs are never auto-retried — an LLM turn is not + * idempotent; retry is a visible user action (ADR-001 §03). + */ + firstTokenTimeoutMs: number; /** Max characters accepted from the user per message. */ maxInputLength: number; - /** Permission required to see the panel. */ - requiredPermission: string; + /** Permissions (any of) that allow seeing the panel. */ + allowedPermissions: string[]; } export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { mcpBaseUrl: environment.copilotMcpBaseUrl, - requestTimeoutMs: 15_000, - maxRetries: 3, + firstTokenTimeoutMs: 60_000, maxInputLength: 500, - requiredPermission: 'READ_COPILOT' + allowedPermissions: [ + 'ALL_FUNCTIONS', + 'USE_MCP_TOOLS', + 'READ_COPILOT' + ] }; /** DI token so the host app can override config at bootstrap. */ diff --git a/src/app/copilot/core/idempotency.ts b/src/app/copilot/core/idempotency.ts index 5cadf4980e..60ee90fe71 100644 --- a/src/app/copilot/core/idempotency.ts +++ b/src/app/copilot/core/idempotency.ts @@ -7,16 +7,20 @@ */ /** - * Generates idempotency / correlation keys for write actions. - * The same key flows from the chat message through the MCP server into - * Fineract's CommandSource table and the audit trail, so a retry or - * double-click can never execute twice. Pure logic, see idempotency.spec.ts. + * Generates display/correlation keys for write actions. + * + * DISPLAY-ONLY (ADR-001 §04): the gateway mints the authoritative + * Idempotency-Key server-side at action-card creation and ignores any + * client-supplied key — a client-controllable dedup key would be a tampering + * vector against Fineract's CommandSource dedup. This factory remains for + * client-side labels and tracing only. Pure logic, see idempotency.spec.ts. */ export class IdempotencyKeyFactory { /** - * Build a key, e.g. `usr-42-approve_and_disburse_loan-107-1719360000000`. - * Deterministic: identical inputs always yield the same key, so a retry or - * double-click reuses it and Fineract deduplicates. Timestamp is injected + * Build a display key, e.g. `usr-42-approve_and_disburse_loan-107-1719360000000`. + * Deterministic: identical inputs always yield the same key, which makes it a stable + * label for tracing. It is NOT sent to Fineract and does not drive deduplication; + * the gateway mints the authoritative Idempotency-Key. Timestamp is injected * (Date.now() is not called here) to keep the function pure and testable. */ generate(userId: number, toolName: string, entityId: number, timestamp: number): string { diff --git a/src/app/copilot/core/input-sanitizer.ts b/src/app/copilot/core/input-sanitizer.ts index 630760f855..303bc835ba 100644 --- a/src/app/copilot/core/input-sanitizer.ts +++ b/src/app/copilot/core/input-sanitizer.ts @@ -36,6 +36,9 @@ const INJECTION_PATTERNS: RegExp[] = [ * Pure logic, no Angular dependency - see input-sanitizer.spec.ts. */ export class InputSanitizer { + /** Configurable so deployments can tighten the limit via CopilotConfig. */ + constructor(private readonly maxLength: number = MAX_INPUT_LENGTH) {} + /** Remove script/style blocks, HTML tags, invisible/control characters, then collapse whitespace. */ stripDangerous(input: string): string { return (input ?? '') @@ -55,7 +58,7 @@ export class InputSanitizer { /** Clean -> length check -> injection check. Returns the cleaned text when allowed. */ sanitize(input: string): SanitizeResult { const cleaned = this.stripDangerous(input); - if (cleaned.length === 0 || cleaned.length > MAX_INPUT_LENGTH) { + if (cleaned.length === 0 || cleaned.length > this.maxLength) { return { blocked: true, reason: 'invalid_length' }; } if (this.matchesInjectionPattern(cleaned)) { diff --git a/src/app/copilot/core/mcp-client.spec.ts b/src/app/copilot/core/mcp-client.spec.ts new file mode 100644 index 0000000000..197161764a --- /dev/null +++ b/src/app/copilot/core/mcp-client.spec.ts @@ -0,0 +1,223 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { describe, it, expect, jest } from '@jest/globals'; +import { TextDecoder as NodeTextDecoder, TextEncoder as NodeTextEncoder } from 'util'; + +import { McpClient } from './mcp-client'; +import { McpChatRequest, McpStreamEvent } from './models/mcp-response.model'; + +// jsdom does not expose the encoding globals the streaming client uses. +(globalThis as any).TextEncoder ??= NodeTextEncoder; +(globalThis as any).TextDecoder ??= NodeTextDecoder; + +const REQUEST: McpChatRequest = { + message: 'Show loans for Rajesh', + clientMsgId: 'msg-1', + context: { + clientId: 42, + clientName: 'Rajesh Kumar', + loanId: null, + screen: 'client-detail', + loggedInUser: 'priya', + role: 'Loan Officer', + language: 'en' + } +}; + +/** Builds a fetch mock whose body yields the given raw SSE chunks in order. */ +function fetchWithChunks(chunks: string[], init: Partial = {}): typeof fetch { + const encoder = new NodeTextEncoder(); + const queue = chunks.map((chunk) => encoder.encode(chunk) as Uint8Array); + const reader = { + read: jest.fn(async () => { + const value = queue.shift(); + return value ? { done: false, value } : { done: true, value: undefined }; + }) + }; + return jest.fn(async () => ({ + ok: true, + status: 200, + body: { getReader: () => reader }, + ...init + })) as unknown as typeof fetch; +} + +async function collect(iterable: AsyncIterable): Promise { + const events: McpStreamEvent[] = []; + for await (const event of iterable) { + events.push(event); + } + return events; +} + +function client(fetchFn: typeof fetch, firstTokenTimeoutMs = 60_000): McpClient { + return new McpClient({ baseUrl: 'https://gateway.example', firstTokenTimeoutMs, fetchFn }); +} + +describe('McpClient', () => { + it('parses token, suggest and done events from an SSE stream', async () => { + const fetchFn = fetchWithChunks([ + 'event: token\ndata: {"delta":"Hel"}\n\n', + 'event: token\ndata: {"delta":"lo"}\n\nevent: suggest\ndata: {"items":["Show schedule"]}\n\n', + 'event: done\ndata: {"conversation_id":"c-1"}\n\n' + ]); + const events = await collect(client(fetchFn).chat(REQUEST, {})); + + expect(events.map((event) => event.type)).toEqual([ + 'token', + 'token', + 'suggest', + 'done' + ]); + expect(events[0].token).toBe('Hel'); + expect(events[2].suggestions).toEqual(['Show schedule']); + expect(events[3].conversationId).toBe('c-1'); + }); + + it('reassembles frames split across chunk boundaries and CRLF newlines', async () => { + const fetchFn = fetchWithChunks([ + 'event: token\r\ndata: {"del', + 'ta":"Hi"}\r\n\r\nevent: done\r\ndata: {}\r\n\r\n' + ]); + const events = await collect(client(fetchFn).chat(REQUEST, {})); + + expect(events.map((event) => event.type)).toEqual([ + 'token', + 'done' + ]); + expect(events[0].token).toBe('Hi'); + }); + + it('maps a snake_case action_card payload to a PendingAction', async () => { + const fetchFn = fetchWithChunks([ + 'event: action_card\ndata: {"card_id":"card-7","tool":"fineract_loan_approve",' + + '"args":{"loanId":4521},"human_summary":"Approve loan #4521","idempotency_key":"srv-1",' + + '"expires_at":"2026-08-09T12:00:00Z"}\n\n', + 'event: done\ndata: {}\n\n' + ]); + const events = await collect(client(fetchFn).chat(REQUEST, {})); + + expect(events[0].pendingAction).toEqual({ + cardId: 'card-7', + tool: 'fineract_loan_approve', + args: { loanId: 4521 }, + humanSummary: 'Approve loan #4521', + idempotencyKey: 'srv-1', + expiresAt: '2026-08-09T12:00:00Z' + }); + }); + + it('skips malformed frames without surfacing an error', async () => { + const fetchFn = fetchWithChunks([ + 'event: token\ndata: {not json}\n\n', + 'event: token\ndata: {"delta":"ok"}\n\n', + 'event: done\ndata: {}\n\n' + ]); + const events = await collect(client(fetchFn).chat(REQUEST, {})); + + expect(events.map((event) => event.type)).toEqual([ + 'token', + 'done' + ]); + expect(events[0].token).toBe('ok'); + }); + + it('stops yielding after the done event even when more frames follow', async () => { + const fetchFn = fetchWithChunks([ + 'event: done\ndata: {}\n\nevent: token\ndata: {"delta":"late"}\n\n' + ]); + const events = await collect(client(fetchFn).chat(REQUEST, {})); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('done'); + }); + + it('maps HTTP 401 to a retryable AUTH_EXPIRED error event', async () => { + const fetchFn = jest.fn(async () => ({ + ok: false, + status: 401, + json: async () => ({ message: 'Token expired' }) + })) as unknown as typeof fetch; + const events = await collect(client(fetchFn).chat(REQUEST, {})); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'error', + errorCode: 'AUTH_EXPIRED', + message: 'Token expired', + retryable: true + }); + }); + + it('sends contract headers and never retries the POST', async () => { + const fetchFn = fetchWithChunks(['event: done\ndata: {}\n\n']); + await collect( + client(fetchFn).chat(REQUEST, { Authorization: 'Basic abc', 'Fineract-Platform-TenantId': 'default' }) + ); + + expect(fetchFn).toHaveBeenCalledTimes(1); + const [ + url, + init + ] = (fetchFn as jest.Mock).mock.calls[0] as [ + string, + RequestInit + ]; + expect(url).toBe('https://gateway.example/copilot/api/v1/chat'); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Authorization']).toBe('Basic abc'); + expect((init.headers as Record)['Accept']).toBe('text/event-stream'); + expect(JSON.parse(init.body as string).clientMsgId).toBe('msg-1'); + }); + + it('targets the decision endpoint for approvals', async () => { + const fetchFn = fetchWithChunks(['event: done\ndata: {}\n\n']); + await collect(client(fetchFn).decision('card-7', { decision: 'approve', clientMsgId: 'msg-2' }, {})); + + const [url] = (fetchFn as jest.Mock).mock.calls[0] as [string]; + expect(url).toBe('https://gateway.example/copilot/api/v1/actions/card-7/decision'); + }); + + it('ends silently when the caller aborts (stop is not an error)', async () => { + const controller = new AbortController(); + const fetchFn = jest.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }) + ) as unknown as typeof fetch; + + const iterator = client(fetchFn).chat(REQUEST, {}, controller.signal)[Symbol.asyncIterator](); + const pending = iterator.next(); + controller.abort(); + + await expect(pending).resolves.toEqual({ done: true, value: undefined }); + }); + + it('yields a retryable LLM_UNAVAILABLE error when the first token times out', async () => { + jest.useFakeTimers(); + try { + const fetchFn = jest.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }) + ) as unknown as typeof fetch; + + const iterator = client(fetchFn, 60_000).chat(REQUEST, {})[Symbol.asyncIterator](); + const pending = iterator.next(); + await jest.advanceTimersByTimeAsync(60_000); + + const { value } = await pending; + expect(value).toMatchObject({ type: 'error', errorCode: 'LLM_UNAVAILABLE', retryable: true }); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/app/copilot/core/mcp-client.ts b/src/app/copilot/core/mcp-client.ts index 4b20c355be..23a62e4646 100644 --- a/src/app/copilot/core/mcp-client.ts +++ b/src/app/copilot/core/mcp-client.ts @@ -6,31 +6,333 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { McpStreamEvent } from './models/mcp-response.model'; +import { + McpChatRequest, + McpDecisionRequest, + McpErrorCode, + McpStreamEvent, + McpStreamEventType, + PendingAction +} from './models/mcp-response.model'; +import { ActionCard } from './models/action-card.model'; -/** Transport-level options for the MCP connection. */ +/** Transport-level options for the Copilot gateway connection. */ export interface McpClientOptions { + /** Gateway origin, e.g. https://gateway.example.com (no trailing slash needed). */ baseUrl: string; - /** Per-request timeout in ms (proposal: 15s). */ - timeoutMs: number; - /** Max retry attempts with exponential backoff (proposal: 3). */ - maxRetries: number; + /** + * Time allowed until the FIRST chunk arrives. LLM turns routinely take tens + * of seconds before the first token, so this is deliberately long (ADR-001). + */ + firstTokenTimeoutMs: number; + /** Injectable for unit tests; defaults to the global fetch. */ + fetchFn?: typeof fetch; } +const CHAT_PATH = '/copilot/api/v1/chat'; +/** Card ids are gateway-minted plain tokens; anything else is refused (path-injection guard). */ +const CARD_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +const VALID_EVENT_TYPES: ReadonlyArray = [ + 'token', + 'tool_call', + 'action_card', + 'suggest', + 'done', + 'error' +]; + /** - * Low-level, framework-agnostic MCP transport: opens the SSE stream and - * yields parsed events. Has no Angular dependency so it can be unit-tested - * against a mock EventSource. The DI wrapper lives in services/mcp-client.service.ts. + * Low-level, framework-agnostic transport for wire contract v1: POSTs a JSON + * body and consumes the SSE response via fetch + ReadableStream. Native + * EventSource is unusable here — it can neither POST a body nor set the + * Authorization/tenant headers this contract requires. + * + * Retry policy (ADR-001): a chat turn is NOT idempotent, so this client never + * retries automatically. Cancel/retry is a visible user action. + * + * No Angular dependency — see mcp-client.spec.ts. The DI wrapper lives in + * services/mcp-client.service.ts. */ export class McpClient { constructor(private readonly options: McpClientOptions) {} + /** Stream a chat turn. */ + chat(request: McpChatRequest, headers: Record, signal?: AbortSignal): AsyncIterable { + return this.stream(CHAT_PATH, request, headers, signal); + } + + /** Approve/reject a pending action; the response continues the paused turn. */ + decision( + cardId: string, + request: McpDecisionRequest, + headers: Record, + signal?: AbortSignal + ): AsyncIterable { + if (!CARD_ID_PATTERN.test(cardId)) { + // Dot-segments etc. survive encodeURIComponent — refuse anything but plain ids. + return this.singleErrorStream('INTERNAL', 'Invalid confirmation reference.', false); + } + const path = `/copilot/api/v1/actions/${encodeURIComponent(cardId)}/decision`; + return this.stream(path, request, headers, signal); + } + + private async *singleErrorStream( + code: McpErrorCode, + message: string, + retryable: boolean + ): AsyncGenerator { + yield this.errorEvent(code, message, retryable); + } + /** - * Open an SSE stream for a chat turn. Callers subscribe to receive - * token / tool_call / action_card / done / error events. + * POST `body` and yield parsed SSE events until 'done', end-of-stream, or + * abort. Transport failures surface as a terminal 'error' event rather than + * a thrown exception, so consumers have a single event-driven code path. + * A user-initiated abort ends the stream silently — stopping is not an error. */ - stream(_payload: { message: string; context: unknown; idempotencyKey?: string }): AsyncIterable { - // TODO: open EventSource, apply timeout + backoff, yield events. - throw new Error('Not implemented'); + private async *stream( + path: string, + body: unknown, + headers: Record, + signal?: AbortSignal + ): AsyncGenerator { + if (signal?.aborted) { + return; // Cancelled before the (lazy) generator ever ran — never send the POST. + } + const fetchFn = this.options.fetchFn ?? fetch.bind(globalThis); + const controller = new AbortController(); + const onOuterAbort = () => controller.abort(); + signal?.addEventListener('abort', onOuterAbort); + + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.options.firstTokenTimeoutMs); + + try { + const response = await fetchFn(`${this.options.baseUrl.replace(/\/+$/, '')}${path}`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Accept: 'text/event-stream' + }, + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!response.ok) { + yield await this.httpError(response); + return; + } + if (!response.body) { + yield this.errorEvent('INTERNAL', 'Empty response body', false); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let firstChunk = true; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (firstChunk) { + clearTimeout(timeout); + firstChunk = false; + } + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, '\n'); + + let separator: number; + while ((separator = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, separator); + buffer = buffer.slice(separator + 2); + const event = this.parseBlock(block); + if (event) { + yield event; + if (event.type === 'done') { + return; + } + } + } + } + } catch (error) { + if (signal?.aborted) { + return; // User pressed stop — end silently. + } + if (timedOut) { + yield this.errorEvent('LLM_UNAVAILABLE', 'Timed out waiting for the assistant to respond.', true); + return; + } + yield this.errorEvent('INTERNAL', error instanceof Error ? error.message : String(error), false); + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', onOuterAbort); + // Release the connection on EVERY exit path. Without this, a gateway that keeps the + // socket open after 'done' (keep-alive comments are a normal SSE pattern) would leak + // one locked stream per turn until the browser's per-host connection limit stalls + // all further requests. + controller.abort(); + } + } + + /** Parse one SSE block ("event: x\ndata: {...}") into a typed event; null when malformed. */ + private parseBlock(block: string): McpStreamEvent | null { + let eventName = ''; + const dataLines: string[] = []; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) { + eventName = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()); + } + // ':' comments and 'id:'/'retry:' fields are intentionally ignored. + } + if (dataLines.length === 0) { + return null; + } + let payload: Record; + try { + payload = JSON.parse(dataLines.join('\n')); + } catch { + return null; // Malformed frames are skipped, never surfaced to the UI. + } + if (!payload || typeof payload !== 'object') { + return null; + } + const type = (eventName || (payload['type'] as string) || '') as McpStreamEventType; + if (!VALID_EVENT_TYPES.includes(type)) { + return null; + } + return this.normalize(type, payload); + } + + /** Map a contract v1 payload (snake_case) onto the frontend event model. */ + private normalize(type: McpStreamEventType, payload: Record): McpStreamEvent { + switch (type) { + case 'token': + return { type, token: String(payload['delta'] ?? payload['token'] ?? '') }; + case 'tool_call': + return { + type, + toolName: payload['tool'] != null ? String(payload['tool']) : undefined, + toolPhase: payload['phase'] === 'finished' ? 'finished' : 'started', + readOnly: payload['read_only'] === true, + summary: payload['summary'] != null ? String(payload['summary']) : undefined + }; + case 'action_card': + return this.normalizeActionCard(payload); + case 'suggest': { + const items = Array.isArray(payload['items']) ? payload['items'] : []; + return { type, suggestions: items.map((item) => String(item)).filter((item) => item.trim().length > 0) }; + } + case 'done': + return { + type, + conversationId: payload['conversation_id'] != null ? String(payload['conversation_id']) : undefined + }; + case 'error': + return this.errorEvent( + this.toErrorCode(payload['code']), + payload['message'] != null ? String(payload['message']) : 'Something went wrong.', + payload['retryable'] === true + ); + default: + return { type }; + } + } + + /** An action_card with a card_id awaits approval; without one it is display-only. */ + private normalizeActionCard(payload: Record): McpStreamEvent { + const cardId = payload['card_id']; + if (cardId != null && !CARD_ID_PATTERN.test(String(cardId))) { + // Malformed id: refuse the action AND tell the officer, so a confirmation + // prompt is never left on screen with no card and no explanation. + return this.errorEvent('INTERNAL', 'Invalid confirmation reference.', false); + } + if (cardId != null) { + const pendingAction: PendingAction = { + cardId: String(cardId), + tool: String(payload['tool'] ?? ''), + args: this.toRecord(payload['args']), + humanSummary: String(payload['human_summary'] ?? payload['summary'] ?? ''), + idempotencyKey: payload['idempotency_key'] != null ? String(payload['idempotency_key']) : undefined, + expiresAt: payload['expires_at'] != null ? String(payload['expires_at']) : undefined + }; + return { type: 'action_card', pendingAction }; + } + const card = payload['card']; + if (card && typeof card === 'object') { + return { type: 'action_card', card: card as ActionCard }; + } + return { type: 'action_card' }; + } + + /** Map an HTTP failure to a contract error event (401/403/429/503 are meaningful). */ + private async httpError(response: Response): Promise { + let code: McpErrorCode; + let retryable = false; + switch (response.status) { + case 401: + code = 'AUTH_EXPIRED'; + retryable = true; + break; + case 403: + code = 'PERMISSION_DENIED'; + break; + case 429: + code = 'RATE_LIMITED'; + retryable = true; + break; + case 502: + case 503: + case 504: + code = 'LLM_UNAVAILABLE'; + retryable = true; + break; + default: + code = 'INTERNAL'; + } + let message = `Request failed (${response.status})`; + try { + const parsed = await response.json(); + if (parsed && typeof parsed === 'object') { + message = String(parsed.message ?? message); + code = this.toErrorCode(parsed.code ?? code); + if (typeof parsed.retryable === 'boolean') { + retryable = parsed.retryable; + } + } + } catch { + // Non-JSON error body — keep the status-derived defaults. + } + return this.errorEvent(code, message, retryable); + } + + private toErrorCode(value: unknown): McpErrorCode { + const codes: McpErrorCode[] = [ + 'AUTH_EXPIRED', + 'PERMISSION_DENIED', + 'LLM_UNAVAILABLE', + 'TOOL_FAILED', + 'RATE_LIMITED', + 'CANCELLED', + 'INTERNAL' + ]; + return codes.includes(value as McpErrorCode) ? (value as McpErrorCode) : 'INTERNAL'; + } + + private errorEvent(errorCode: McpErrorCode, message: string, retryable: boolean): McpStreamEvent { + return { type: 'error', errorCode, message, retryable }; + } + + private toRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } } diff --git a/src/app/copilot/core/models/copilot-context.model.ts b/src/app/copilot/core/models/copilot-context.model.ts index 57ee15fbfe..1bf4fd3480 100644 --- a/src/app/copilot/core/models/copilot-context.model.ts +++ b/src/app/copilot/core/models/copilot-context.model.ts @@ -26,4 +26,10 @@ export interface CopilotContext { role: string; /** Two-letter language code ('en', 'es', 'sw', ...) for AI responses. */ language: string; + /** + * Origin of the Fineract backend THIS UI is connected to. The gateway compares it + * against its own FINERACT_BASE_URL and refuses to run on a mismatch — the Copilot + * must never write to a different bank than the one on the officer's screen. + */ + backendOrigin?: string; } diff --git a/src/app/copilot/core/models/mcp-response.model.ts b/src/app/copilot/core/models/mcp-response.model.ts index 76abb55d95..08cd455360 100644 --- a/src/app/copilot/core/models/mcp-response.model.ts +++ b/src/app/copilot/core/models/mcp-response.model.ts @@ -7,22 +7,79 @@ */ import { ActionCard } from './action-card.model'; +import { CopilotContext } from './copilot-context.model'; + +/** + * Wire contract v1 (ADR-001) between the web-app and the Copilot gateway. + * The gateway streams typed SSE events; the frontend never derives approval + * state from model prose — action cards arrive as structured events. + */ /** Discriminator for events arriving over the SSE stream. */ -export type McpStreamEventType = 'token' | 'tool_call' | 'action_card' | 'done' | 'error'; +export type McpStreamEventType = 'token' | 'tool_call' | 'action_card' | 'suggest' | 'done' | 'error'; + +/** Error codes defined by wire contract v1. */ +export type McpErrorCode = + 'AUTH_EXPIRED' | 'PERMISSION_DENIED' | 'LLM_UNAVAILABLE' | 'TOOL_FAILED' | 'RATE_LIMITED' | 'CANCELLED' | 'INTERNAL'; + +/** + * A write action awaiting human confirmation. Constructed server-side from the + * model's parsed function call — never authored by the model as prose. + */ +export interface PendingAction { + /** Server-issued id used to approve/reject via the decision endpoint. */ + cardId: string; + /** MCP tool the gateway will execute on approval. */ + tool: string; + /** Parsed tool arguments, shown to the officer verbatim. */ + args: Record; + /** One-sentence summary of what will happen, built by the gateway. */ + humanSummary: string; + /** Display-only. The gateway's copy is authoritative (ADR-001 §04). */ + idempotencyKey?: string; + /** ISO timestamp after which the card can no longer be approved. */ + expiresAt?: string; +} -/** A single Server-Sent Event from the MCP server. */ +/** A single Server-Sent Event from the Copilot gateway. */ export interface McpStreamEvent { type: McpStreamEventType; /** Present for 'token' events: the streamed text fragment. */ token?: string; /** Present for 'tool_call' events. */ toolName?: string; - toolArgs?: Record; - /** Present for 'action_card' events. */ + toolPhase?: 'started' | 'finished'; + readOnly?: boolean; + summary?: string; + /** Present for 'action_card' events that only display data (no approval). */ card?: ActionCard; + /** Present for 'action_card' events that require human approval. */ + pendingAction?: PendingAction; + /** Present for 'suggest' events: follow-up prompts. */ + suggestions?: string[]; + /** Present for 'done' events. */ + conversationId?: string; /** Present for 'error' events. */ + errorCode?: McpErrorCode; message?: string; + retryable?: boolean; +} + +/** Body of POST /copilot/api/v1/chat. */ +export interface McpChatRequest { + /** Omitted for the first turn; the gateway assigns one via 'done'. */ + conversationId?: string; + message: string; + /** Client-generated id for tracing; NOT an idempotency key (server mints those). */ + clientMsgId: string; + /** Context silently attached to every turn (screen, client, role, language). */ + context: CopilotContext; +} + +/** Body of POST /copilot/api/v1/actions/{cardId}/decision. */ +export interface McpDecisionRequest { + decision: 'approve' | 'reject'; + clientMsgId: string; } /** Fully assembled response after a stream completes. */ diff --git a/src/app/copilot/core/response-parser.spec.ts b/src/app/copilot/core/response-parser.spec.ts index 5e95d84a6f..b9938e0cf5 100644 --- a/src/app/copilot/core/response-parser.spec.ts +++ b/src/app/copilot/core/response-parser.spec.ts @@ -16,90 +16,37 @@ describe('ResponseParser', () => { parser = new ResponseParser(); }); - it('parses a valid action card', () => { - const raw = - 'Here are the details.\n```action_card\n{"type":"loan","title":"Loan #107","data":{"Status":"Active","Balance":"5000"}}\n```'; - const cards = parser.parseCards(raw); - expect(cards).toHaveLength(1); - expect(cards[0].type).toBe('loan'); - expect(cards[0].title).toBe('Loan #107'); - expect(cards[0].data['Balance']).toBe('5000'); - }); - - it('parses multiple cards in one response', () => { - const raw = - '```action_card\n{"type":"client","title":"Rajesh","data":{}}\n```\n```action_card\n{"type":"savings","title":"Acct","data":{"Bal":"10"}}\n```'; - expect(parser.parseCards(raw)).toHaveLength(2); - }); - - it('skips malformed JSON without throwing', () => { - const raw = '```action_card\n{not valid json}\n```'; - expect(parser.parseCards(raw)).toEqual([]); - }); - - it('skips cards with an invalid type', () => { - const raw = '```action_card\n{"type":"wizardry","title":"X","data":{}}\n```'; - expect(parser.parseCards(raw)).toEqual([]); - }); - - it('skips cards missing a title or data', () => { - expect(parser.parseCards('```action_card\n{"type":"loan","data":{}}\n```')).toEqual([]); - expect(parser.parseCards('```action_card\n{"type":"loan","title":"X"}\n```')).toEqual([]); - }); - - it('coerces non-string data values to strings', () => { - const raw = '```action_card\n{"type":"loan","title":"L","data":{"n":5,"ok":true,"x":null}}\n```'; - const [card] = parser.parseCards(raw); - expect(card.data).toEqual({ n: '5', ok: 'true', x: '' }); - }); - - it('parses suggestion blocks, stripping list markers', () => { - const raw = '```suggest\n- Show client portfolio\n2. View overdue loans\n```'; + it('parses suggestions one per line, stripping list markers', () => { + const raw = 'Done.\n```suggest\n- Show repayment schedule\n2) Record a repayment\n• Check balance\n```'; expect(parser.parseSuggestions(raw)).toEqual([ - 'Show client portfolio', - 'View overdue loans' + 'Show repayment schedule', + 'Record a repayment', + 'Check balance' ]); }); - it('keeps leading numbers that are part of the suggestion text', () => { - const raw = '```suggest\n2026 portfolio summary\n10% arrears report\n```'; + it('collects suggestions across multiple fenced blocks', () => { + const raw = '```suggest\nFirst\n```\ntext\n```suggest\nSecond\n```'; expect(parser.parseSuggestions(raw)).toEqual([ - '2026 portfolio summary', - '10% arrears report' + 'First', + 'Second' ]); }); - it('assembles text with fences stripped and blank lines collapsed', () => { - const raw = - 'Summary line.\n\n```action_card\n{"type":"loan","title":"L","data":{}}\n```\n\n```suggest\nMore info\n```'; - const result = parser.parse(raw); - expect(result.text).toBe('Summary line.'); - expect(result.actionCards).toHaveLength(1); - expect(result.suggestedPrompts).toEqual(['More info']); - }); - - it('returns safe empties for empty, null, and plain-text input', () => { - expect(parser.parse('')).toEqual({ text: '', actionCards: [], suggestedPrompts: [] }); - expect(parser.parse(null as unknown as string).actionCards).toEqual([]); - const plain = parser.parse('Just a plain answer with no blocks.'); - expect(plain.text).toBe('Just a plain answer with no blocks.'); - expect(plain.actionCards).toEqual([]); - expect(plain.suggestedPrompts).toEqual([]); + it('ignores empty lines inside the block', () => { + expect(parser.parseSuggestions('```suggest\n\nOnly one\n\n```')).toEqual(['Only one']); }); - it('keeps action button definitions when present', () => { - const raw = - '```action_card\n{"type":"confirmation","title":"Confirm","data":{},"actions":[{"label":"Confirm","style":"warn","action":"approve"}]}\n```'; - const [card] = parser.parseCards(raw); - expect(card.actions).toHaveLength(1); - expect(card.actions?.[0].label).toBe('Confirm'); + it('returns an empty list when no block is present or input is nullish', () => { + expect(parser.parseSuggestions('plain prose')).toEqual([]); + expect(parser.parseSuggestions('')).toEqual([]); + expect(parser.parseSuggestions(undefined as unknown as string)).toEqual([]); }); - it('drops malformed action buttons (bad style, missing label)', () => { - const raw = - '```action_card\n{"type":"loan","title":"L","data":{},"actions":[{"label":"Ok","style":"warn"},{"label":"Bad","style":"explode"},{"style":"primary"}]}\n```'; - const [card] = parser.parseCards(raw); - expect(card.actions).toHaveLength(1); - expect(card.actions?.[0].label).toBe('Ok'); + it('does NOT expose any action_card parsing (retired per ADR-001 §04)', () => { + // Approvable cards come only from typed SSE events built server-side; prose that + // looks like an action card must never become an approvable object. + expect((parser as unknown as Record)['parseCards']).toBeUndefined(); + expect((parser as unknown as Record)['parse']).toBeUndefined(); }); }); diff --git a/src/app/copilot/core/response-parser.ts b/src/app/copilot/core/response-parser.ts index 5225389513..e26b8cf36d 100644 --- a/src/app/copilot/core/response-parser.ts +++ b/src/app/copilot/core/response-parser.ts @@ -6,43 +6,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { McpResponse } from './models/mcp-response.model'; -import { ActionCard, ActionButtonStyle, ActionCardButton, ActionCardType } from './models/action-card.model'; - -const CARD_BLOCK = /```action_card\s*([\s\S]*?)```/gi; const SUGGEST_BLOCK = /```suggest\s*([\s\S]*?)```/gi; -const VALID_TYPES: ReadonlyArray = [ - 'client', - 'loan', - 'savings', - 'insight', - 'confirmation' -]; -const VALID_STYLES: ReadonlyArray = [ - 'primary', - 'warn', - 'accent' -]; /** - * Parses assistant output into structured action cards and follow-up prompts. - * The assistant is instructed (via the system prompt) to emit ```action_card``` - * and ```suggest``` fenced blocks. Parsing always degrades gracefully and never - * throws to the UI: malformed blocks are skipped and bad input yields safe empties. + * Extracts follow-up prompts from fenced ```suggest``` blocks in assistant prose. + * + * Deliberately narrow (ADR-001 §04): approvable action cards arrive ONLY as typed SSE + * events constructed server-side from the parsed function call. Model prose that merely + * looks like an action card renders as plain text — the fenced ```action_card``` parsing + * path was removed so the LLM can never author the content a human approves against. */ export class ResponseParser { - /** Extract valid action cards from fenced ```action_card``` JSON blocks. */ - parseCards(raw: string): ActionCard[] { - const cards: ActionCard[] = []; - for (const match of (raw ?? '').matchAll(CARD_BLOCK)) { - const card = this.toCard(match[1]); - if (card) { - cards.push(card); - } - } - return cards; - } - /** Extract follow-up prompts from fenced ```suggest``` blocks (one per line). */ parseSuggestions(raw: string): string[] { const suggestions: string[] = []; @@ -56,103 +30,4 @@ export class ResponseParser { } return suggestions; } - - /** Assemble the full response: prose (fences stripped) plus cards and suggestions. */ - parse(raw: string): McpResponse { - const text = (raw ?? '') - .replace(CARD_BLOCK, '') - .replace(SUGGEST_BLOCK, '') - .replace(/\n{3,}/g, '\n\n') - .trim(); - return { - text, - actionCards: this.parseCards(raw), - suggestedPrompts: this.parseSuggestions(raw) - }; - } - - /** Parse and validate a single card block; returns null when malformed. */ - private toCard(jsonBlock: string): ActionCard | null { - let parsed: unknown; - try { - parsed = JSON.parse(jsonBlock.trim()); - } catch { - return null; - } - if (!parsed || typeof parsed !== 'object') { - return null; - } - const candidate = parsed as Record; - const type = candidate['type']; - const title = candidate['title']; - const data = candidate['data']; - if (typeof type !== 'string' || !VALID_TYPES.includes(type as ActionCardType)) { - return null; - } - if (typeof title !== 'string' || !title.trim()) { - return null; - } - if (!data || typeof data !== 'object' || Array.isArray(data)) { - return null; - } - const card: ActionCard = { - type: type as ActionCardType, - title, - data: this.toStringRecord(data as Record) - }; - const actions = this.toActions(candidate['actions']); - if (actions.length) { - card.actions = actions; - } - return card; - } - - /** Validate the actions array, keeping only well-formed buttons. */ - private toActions(raw: unknown): ActionCardButton[] { - if (!Array.isArray(raw)) { - return []; - } - const buttons: ActionCardButton[] = []; - for (const item of raw) { - if (!item || typeof item !== 'object') { - continue; - } - const entry = item as Record; - const label = entry['label']; - const style = entry['style']; - if (typeof label !== 'string' || !label.trim()) { - continue; - } - if (typeof style !== 'string' || !VALID_STYLES.includes(style as ActionButtonStyle)) { - continue; - } - const button: ActionCardButton = { label, style: style as ActionButtonStyle }; - if (typeof entry['action'] === 'string') { - button.action = entry['action']; - } - if (typeof entry['route'] === 'string') { - button.route = entry['route']; - } - buttons.push(button); - } - return buttons; - } - - /** Coerce a data object's values to display strings (objects are JSON-encoded). */ - private toStringRecord(input: Record): Record { - const out: Record = {}; - for (const [ - key, - value - ] of Object.entries(input)) { - if (value == null) { - out[key] = ''; - } else if (typeof value === 'object') { - out[key] = JSON.stringify(value); - } else { - out[key] = String(value); - } - } - return out; - } } diff --git a/src/app/copilot/pipes/markdown.pipe.spec.ts b/src/app/copilot/pipes/markdown.pipe.spec.ts new file mode 100644 index 0000000000..7d63e46a30 --- /dev/null +++ b/src/app/copilot/pipes/markdown.pipe.spec.ts @@ -0,0 +1,56 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach } from '@jest/globals'; + +import { MarkdownPipe } from './markdown.pipe'; + +describe('MarkdownPipe', () => { + let pipe: MarkdownPipe; + + beforeEach(() => { + TestBed.configureTestingModule({}); + pipe = TestBed.runInInjectionContext(() => new MarkdownPipe()); + }); + + /** The pipe escapes first; render() operates on escaped text. */ + const render = (raw: string): string => (pipe as any).render((pipe as any).escapeHtml(raw)); + + it('renders a markdown table as real rows and columns', () => { + const raw = + '| Client ID | Name | Status |\n|:---|:---|:---|\n| 1 | Anita Desai | Active |\n| 2 | Sunita Verma | Active |'; + const html = render(raw); + + expect(html).toContain('Client ID'); + expect(html).toContain(''); + expect(html).toContain(''); + expect((html.match(//g) || []).length).toBe(3); // header + 2 body rows + }); + + it('a lone pipe-ish line without a separator stays plain text', () => { + const html = render('| just | text |'); + expect(html).not.toContain(' { + const raw = '| A |\n|---|\n| |'; + const html = render(raw); + expect(html).not.toContain('
Anita DesaiSunita Verma