From d8b4a1a6c960a01b6876bbffed3f618303380764 Mon Sep 17 00:00:00 2001 From: Jose Alberto Hernandez Date: Mon, 14 Sep 2026 23:26:26 -0500 Subject: [PATCH] WEB-657: Adjust loan transaction --- .../http/error-handler.interceptor.spec.ts | 104 +++++++ .../core/http/error-handler.interceptor.ts | 47 +++- src/app/loans/loans-routing.module.ts | 2 +- .../loan-transaction-adjust.helper.spec.ts | 219 +++++++++++++++ .../loan-transaction-adjust.helper.ts | 114 ++++++++ .../loan-transaction-reversal.helper.ts | 97 +++++++ .../transactions-tab.component.html | 14 +- .../transactions-tab.component.spec.ts | 253 ++++++++++++++++++ .../transactions-tab.component.ts | 141 +++++++++- .../edit-transaction.component.html | 27 +- .../edit-transaction.component.scss | 10 + .../edit-transaction.component.spec.ts | 225 ++++++++++++++++ .../edit-transaction.component.ts | 199 ++++++++++---- .../view-transaction.component.html | 4 +- .../view-transaction.component.spec.ts | 247 +++++++++++++++++ .../view-transaction.component.ts | 248 +++++++++++------ .../form-dialog/form-dialog.component.html | 4 + .../form-dialog/form-dialog.component.scss | 10 + src/assets/translations/cs-CS.json | 27 +- src/assets/translations/de-DE.json | 27 +- src/assets/translations/en-US.json | 27 +- src/assets/translations/es-CL.json | 27 +- src/assets/translations/es-MX.json | 27 +- src/assets/translations/fr-FR.json | 27 +- src/assets/translations/it-IT.json | 27 +- src/assets/translations/ko-KO.json | 27 +- src/assets/translations/lt-LT.json | 27 +- src/assets/translations/lv-LV.json | 27 +- src/assets/translations/ne-NE.json | 27 +- src/assets/translations/pt-PT.json | 27 +- src/assets/translations/sw-SW.json | 27 +- 31 files changed, 2087 insertions(+), 229 deletions(-) create mode 100644 src/app/loans/loans-view/loan-transaction-adjust.helper.spec.ts create mode 100644 src/app/loans/loans-view/loan-transaction-adjust.helper.ts create mode 100644 src/app/loans/loans-view/loan-transaction-reversal.helper.ts create mode 100644 src/app/loans/loans-view/transactions-tab/transactions-tab.component.spec.ts create mode 100644 src/app/loans/loans-view/transactions/edit-transaction/edit-transaction.component.spec.ts create mode 100644 src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.spec.ts diff --git a/src/app/core/http/error-handler.interceptor.spec.ts b/src/app/core/http/error-handler.interceptor.spec.ts index a4457eafe1..12439012f1 100644 --- a/src/app/core/http/error-handler.interceptor.spec.ts +++ b/src/app/core/http/error-handler.interceptor.spec.ts @@ -82,4 +82,108 @@ describe('ErrorHandlerInterceptor', () => { intercept('/fineract-provider/api/v1/businessdate', 404); expect(alert).toHaveBeenCalled(); }); + + describe('domain rule violations', () => { + const REVERSE_ONLY_CODE = 'error.msg.loan.transaction.error.msg.loan.transaction.update.not.allowed'; + + /** + * Encodes the error body the way the browser delivers it to a request that + * asked for an ArrayBuffer response. The buffer is filled byte by byte + * instead of through `TextEncoder`, whose output belongs to another realm + * under jsdom and would therefore fail the interceptor's `instanceof` check. + * @param body Error body sent by the backend + */ + function encodeBody(body: unknown): ArrayBuffer { + const json = JSON.stringify(body); + const buffer = new ArrayBuffer(json.length); + const bytes = new Uint8Array(buffer); + for (let index = 0; index < json.length; index++) { + bytes[index] = json.charCodeAt(index); + } + return buffer; + } + + /** + * Drives the interceptor with the envelope the backend sends for a domain + * rule violation: a generic code on the envelope and the meaningful one on + * the nested error. + * @param nestedCode Globalisation code of the nested error + * @param translations Keys the translate service knows about + * @param asArrayBuffer Sends the body encoded, as requests that ask for an + * ArrayBuffer response receive it + */ + function interceptDomainRuleViolation( + nestedCode: string, + translations: { [key: string]: string } = {}, + asArrayBuffer = false + ): void { + TestBed.resetTestingModule(); + alert = jest.fn(); + TestBed.configureTestingModule({ + providers: [ + ErrorHandlerInterceptor, + { provide: AlertService, useValue: { alert } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => translations[key] ?? key } + } + ] + }); + const localInterceptor = TestBed.inject(ErrorHandlerInterceptor); + const body = { + userMessageGlobalisationCode: 'validation.msg.domain.rule.violation', + defaultUserMessage: 'Request was understood but caused a domain rule violation.', + errors: [ + { + userMessageGlobalisationCode: nestedCode, + defaultUserMessage: 'Loan transaction: 77 update not allowed as loan transaction is a goodwillCredit' + } + ] + }; + const response = new HttpErrorResponse({ + status: 403, + url: '/fineract-provider/api/v1/loans/1/transactions/77', + error: asArrayBuffer ? encodeBody(body) : body + }); + try { + (localInterceptor as any).handleError(response, new HttpRequest('POST', response.url, {})).subscribe({ + error: (): void => undefined + }); + } catch { + // The interceptor rethrows after alerting, which is the path under test. + } + } + + it('shows the translated message for the backend code', () => { + interceptDomainRuleViolation(REVERSE_ONLY_CODE, { + [`errors.${REVERSE_ONLY_CODE}`]: 'This transaction type can only be reversed.' + }); + + expect(alert).toHaveBeenCalledWith( + expect.objectContaining({ message: 'This transaction type can only be reversed.' }) + ); + }); + + it('shows the translated message when the body arrives as an ArrayBuffer', () => { + interceptDomainRuleViolation( + REVERSE_ONLY_CODE, + { [`errors.${REVERSE_ONLY_CODE}`]: 'This transaction type can only be reversed.' }, + true + ); + + expect(alert).toHaveBeenCalledWith( + expect.objectContaining({ message: 'This transaction type can only be reversed.' }) + ); + }); + + it('falls back to the server message when the code has no translation', () => { + interceptDomainRuleViolation(REVERSE_ONLY_CODE); + + expect(alert).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Loan transaction: 77 update not allowed as loan transaction is a goodwillCredit' + }) + ); + }); + }); }); diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index eef8cd8ccb..be2e5918da 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -62,6 +62,21 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { return error; } + /** + * Resolves a backend globalisation code against the `errors` section, where + * the codes are stored as flat dotted keys. Returns null when the code has no + * translation, so the caller can fall back to the server message. + * @param code Globalisation code sent by the backend + */ + private translateErrorCode(code: string | undefined): string | null { + if (!code) { + return null; + } + const key = `errors.${code}`; + const translated = this.translate.instant(key); + return translated && translated !== key ? translated : null; + } + private handleError(response: HttpErrorResponse, request: HttpRequest): Observable> { // Tenant branding is cosmetic and optional: the endpoint is absent on // deployments without the self-service plugin. Let the caller fall back to @@ -106,21 +121,25 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { : nestedMessage : topLevelMessage; let parameterName: string | null = null; - if (response.error.errors) { - if (response.error.errors[0]) { - if ( - response.error.errors[0].userMessageGlobalisationCode && - this.databaseErrorCodes.indexOf(response.error.errors[0].userMessageGlobalisationCode) > -1 - ) { - errorMessage = this.translate.instant('errors.error.msg.data.integrity.issue'); - } else { - errorMessage = - response.error.errors[0].defaultUserMessage.replace(/\\./g, ' ') || - response.error.errors[0].developerMessage.replace(/\\./g, ' '); - } + // Read the nested error from the parsed body rather than from the raw + // response, so a body delivered as an ArrayBuffer goes through the same + // lookup instead of falling back to the untranslated server message. + const nestedError = errorBody?.errors?.[0]; + if (nestedError) { + const nestedCode = nestedError.userMessageGlobalisationCode; + if (nestedCode && this.databaseErrorCodes.indexOf(nestedCode) > -1) { + errorMessage = this.translate.instant('errors.error.msg.data.integrity.issue'); + } else { + // A domain rule violation carries the meaningful code on the nested + // error, not on the envelope, so it is looked up here before falling + // back to the raw message the server sent. + errorMessage = + this.translateErrorCode(nestedCode) || + nestedError.defaultUserMessage?.replace(/\\./g, ' ') || + nestedError.developerMessage?.replace(/\\./g, ' '); } - if ('parameterName' in errorBody.errors[0]) { - parameterName = errorBody.errors[0].parameterName; + if ('parameterName' in nestedError) { + parameterName = nestedError.parameterName; } } const isClientImage404 = status === 404 && request.url.includes('/clients/') && request.url.includes('/images'); diff --git a/src/app/loans/loans-routing.module.ts b/src/app/loans/loans-routing.module.ts index df2c881b2e..9068daff76 100644 --- a/src/app/loans/loans-routing.module.ts +++ b/src/app/loans/loans-routing.module.ts @@ -392,7 +392,7 @@ const routes: Routes = [ { path: 'edit', component: EditTransactionComponent, - data: { breadcrumb: 'Edit', routeParamBreadcrumb: false }, + data: { breadcrumb: 'Adjust', routeParamBreadcrumb: false }, resolve: { loansAccountTransactionTemplate: LoansAccountTransactionTemplateResolver } diff --git a/src/app/loans/loans-view/loan-transaction-adjust.helper.spec.ts b/src/app/loans/loans-view/loan-transaction-adjust.helper.spec.ts new file mode 100644 index 0000000000..dd25728686 --- /dev/null +++ b/src/app/loans/loans-view/loan-transaction-adjust.helper.spec.ts @@ -0,0 +1,219 @@ +/** + * 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 { LoanTransactionType } from 'app/loans/models/loan-transaction-type.model'; +import { + adjustmentReopensLoan, + canAdjustLoanTransaction, + canReverseLoanTransaction, + isReverseOnlyLoanTransaction +} from './loan-transaction-adjust.helper'; + +/** Only the id is read by the gates, so the rest of the type is irrelevant here. */ +function transactionType(id: number): LoanTransactionType { + return { id } as LoanTransactionType; +} + +describe('LoanTransactionAdjustHelper', () => { + const bothModes = [ + [ + 'REPAYMENT', + 2 + ], + [ + 'WAIVE_INTEREST', + 4 + ], + [ + 'WAIVE_CHARGES', + 9 + ], + [ + 'ACCRUAL', + 10 + ], + [ + 'CREDIT_BALANCE_REFUND', + 20 + ], + [ + 'CHARGE_REFUND', + 24 + ], + [ + 'CHARGE_ADJUSTMENT', + 26 + ], + [ + 'DOWN_PAYMENT', + 28 + ], + [ + 'INTEREST_PAYMENT_WAIVER', + 31 + ], + [ + 'ACCRUAL_ACTIVITY', + 32 + ], + [ + 'ACCRUAL_ADJUSTMENT', + 34 + ] + ]; + + const reverseOnly = [ + [ + 'MERCHANT_ISSUED_REFUND', + 21 + ], + [ + 'PAYOUT_REFUND', + 22 + ], + [ + 'GOODWILL_CREDIT', + 23 + ], + [ + 'CAPITALIZED_INCOME', + 35 + ], + [ + 'CAPITALIZED_INCOME_ADJUSTMENT', + 37 + ], + [ + 'BUY_DOWN_FEE', + 40 + ], + [ + 'BUY_DOWN_FEE_ADJUSTMENT', + 41 + ] + ]; + + const noAction = [ + [ + 'DISBURSEMENT', + 1 + ], + [ + 'WRITEOFF', + 6 + ], + [ + 'RECOVERY_REPAYMENT', + 8 + ], + [ + 'REFUND', + 16 + ], + [ + 'CHARGE_PAYMENT', + 17 + ], + [ + 'INCOME_POSTING', + 19 + ], + [ + 'CHARGEBACK', + 25 + ], + [ + 'CHARGE_OFF', + 27 + ], + [ + 'REAGE', + 29 + ], + [ + 'REAMORTIZE', + 30 + ], + [ + 'INTEREST_REFUND', + 33 + ], + [ + 'CAPITALIZED_INCOME_AMORTIZATION', + 36 + ], + [ + 'CONTRACT_TERMINATION', + 38 + ], + [ + 'BUY_DOWN_FEE_AMORTIZATION', + 42 + ], + [ + 'DISCOUNT_FEE', + 44 + ], + [ + 'DISCOUNT_FEE_ADJUSTMENT', + 46 + ] + ]; + + describe.each(bothModes)('%s', (_name, id: number) => { + it('can be reversed and adjusted', () => { + expect(canReverseLoanTransaction(transactionType(id), false)).toBe(true); + expect(canAdjustLoanTransaction(transactionType(id), false)).toBe(true); + expect(isReverseOnlyLoanTransaction(transactionType(id))).toBe(false); + }); + + it('offers no action once reversed', () => { + expect(canReverseLoanTransaction(transactionType(id), true)).toBe(false); + expect(canAdjustLoanTransaction(transactionType(id), true)).toBe(false); + }); + }); + + describe.each(reverseOnly)('%s', (_name, id: number) => { + it('can be reversed but not adjusted', () => { + expect(canReverseLoanTransaction(transactionType(id), false)).toBe(true); + expect(canAdjustLoanTransaction(transactionType(id), false)).toBe(false); + expect(isReverseOnlyLoanTransaction(transactionType(id))).toBe(true); + }); + + it('offers no action once reversed', () => { + expect(canReverseLoanTransaction(transactionType(id), true)).toBe(false); + }); + }); + + describe.each(noAction)('%s', (_name, id: number) => { + it('offers neither action', () => { + expect(canReverseLoanTransaction(transactionType(id), false)).toBe(false); + expect(canAdjustLoanTransaction(transactionType(id), false)).toBe(false); + }); + }); + + describe('adjustmentReopensLoan', () => { + it('warns on a loan closed with its obligations met', () => { + expect(adjustmentReopensLoan({ code: 'loanStatusType.closed.obligations.met' })).toBe(true); + }); + + it('warns on an overpaid loan', () => { + expect(adjustmentReopensLoan({ code: 'loanStatusType.overpaid' })).toBe(true); + }); + + it('stays quiet on a loan that is still open', () => { + expect(adjustmentReopensLoan({ code: 'loanStatusType.active' })).toBe(false); + }); + + it('stays quiet when the status is missing', () => { + expect(adjustmentReopensLoan(null)).toBe(false); + expect(adjustmentReopensLoan(undefined)).toBe(false); + expect(adjustmentReopensLoan({})).toBe(false); + }); + }); +}); diff --git a/src/app/loans/loans-view/loan-transaction-adjust.helper.ts b/src/app/loans/loans-view/loan-transaction-adjust.helper.ts new file mode 100644 index 0000000000..e755a1be24 --- /dev/null +++ b/src/app/loans/loans-view/loan-transaction-adjust.helper.ts @@ -0,0 +1,114 @@ +/** + * 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 { LoanTransactionType } from 'app/loans/models/loan-transaction-type.model'; + +/** + * Availability rules for the Term Loan adjust command + * (`POST /loans/{loanId}/transactions/{transactionId}`), which reverses the + * original transaction and, when a positive amount is supplied, also creates a + * replacement transaction of the same type. + * + * The backend applies two independent gates: a transaction must clear the first + * one to be reversed at all, and both of them to be re-submitted with a new + * amount. Types are matched by id rather than by the boolean flags because the + * type codes are not unique (REFUND and REFUND_FOR_ACTIVE_LOAN share one code) + * and because the flags for ACCRUAL_ACTIVITY and ACCRUAL_ADJUSTMENT are not + * part of the transaction type model. + * + * Working Capital loans are out of scope: they reverse transactions through + * their own commands. + */ + +/** + * Gate 1 - types the adjust command accepts at all. Mirrors the type check in + * the backend's LoanAdjustmentServiceImpl#adjustExistingTransaction: accrual + * related, repayment like, waiver, credit balance refund, deferred income, + * capitalized income adjustment or buy down fee adjustment. + */ +const REVERSIBLE_TRANSACTION_TYPE_IDS: ReadonlySet = new Set([ + 2, // REPAYMENT + 4, // WAIVE_INTEREST + 9, // WAIVE_CHARGES + 10, // ACCRUAL + 20, // CREDIT_BALANCE_REFUND + 21, // MERCHANT_ISSUED_REFUND + 22, // PAYOUT_REFUND + 23, // GOODWILL_CREDIT + 24, // CHARGE_REFUND + 26, // CHARGE_ADJUSTMENT + 28, // DOWN_PAYMENT + 31, // INTEREST_PAYMENT_WAIVER + 32, // ACCRUAL_ACTIVITY + 34, // ACCRUAL_ADJUSTMENT + 35, // CAPITALIZED_INCOME + 37, // CAPITALIZED_INCOME_ADJUSTMENT + 40, // BUY_DOWN_FEE + 41 // BUY_DOWN_FEE_ADJUSTMENT +]); + +/** + * Gate 2 - types that clear gate 1 but reject a positive amount, so they can + * only be reversed. The first three come from the backend's + * LoanTransaction#isEditable check; the deferred income ones each raise their + * own "cannot be adjusted" error. + */ +const REVERSE_ONLY_TRANSACTION_TYPE_IDS: ReadonlySet = new Set([ + 21, // MERCHANT_ISSUED_REFUND + 22, // PAYOUT_REFUND + 23, // GOODWILL_CREDIT + 35, // CAPITALIZED_INCOME + 37, // CAPITALIZED_INCOME_ADJUSTMENT + 40, // BUY_DOWN_FEE + 41 // BUY_DOWN_FEE_ADJUSTMENT +]); + +/** + * Interest refund is repayment like, so it clears gate 1, but the backend + * rejects it before reaching that check: it can be neither reversed nor + * adjusted directly. + */ +const INTEREST_REFUND_TYPE_ID = 33; + +/** + * Loan statuses the command is allowed to act on even though the loan is no + * longer active. The backend re-runs the loan lifecycle state machine after the + * adjustment, which reopens the account. + */ +const REOPENING_LOAN_STATUS_CODES: ReadonlySet = new Set([ + 'loanStatusType.closed.obligations.met', + 'loanStatusType.overpaid' +]); + +/** True when the amount cannot be changed and the only available mode is a reversal. */ +export function isReverseOnlyLoanTransaction(transactionType: LoanTransactionType): boolean { + return REVERSE_ONLY_TRANSACTION_TYPE_IDS.has(transactionType.id); +} + +/** True when the transaction can be reversed through the adjust command. */ +export function canReverseLoanTransaction(transactionType: LoanTransactionType, alreadyReversed: boolean): boolean { + return ( + !alreadyReversed && + transactionType.id !== INTEREST_REFUND_TYPE_ID && + REVERSIBLE_TRANSACTION_TYPE_IDS.has(transactionType.id) + ); +} + +/** True when the transaction can be re-submitted with a new date, amount and payment details. */ +export function canAdjustLoanTransaction(transactionType: LoanTransactionType, alreadyReversed: boolean): boolean { + return canReverseLoanTransaction(transactionType, alreadyReversed) && !isReverseOnlyLoanTransaction(transactionType); +} + +/** + * True when adjusting or reversing a transaction on this loan will reopen it, + * which is allowed but changes the account the user is looking at. + * @param loanStatus Status of the loan the transaction belongs to + */ +export function adjustmentReopensLoan(loanStatus: { code?: string } | null | undefined): boolean { + return !!loanStatus?.code && REOPENING_LOAN_STATUS_CODES.has(loanStatus.code); +} diff --git a/src/app/loans/loans-view/loan-transaction-reversal.helper.ts b/src/app/loans/loans-view/loan-transaction-reversal.helper.ts new file mode 100644 index 0000000000..29f3594457 --- /dev/null +++ b/src/app/loans/loans-view/loan-transaction-reversal.helper.ts @@ -0,0 +1,97 @@ +/** + * 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 { Validators } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base'; +import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base'; + +/** + * Dialog collected before reversing a loan transaction, shared by the + * transaction list and the transaction detail view so both entry points offer + * the same fields and post the same body. + * + * Both fields are optional. The note is attached to the new transaction in + * adjust mode and to the original one in reverse mode; the reversal external id + * is stamped on the reversal itself and is never auto-generated. + */ + +/** + * Caution shown when the loan is closed or overpaid: the command is accepted, + * but the backend re-runs the loan lifecycle state machine and reopens the + * account. + */ +export const REOPEN_LOAN_WARNING_KEY = 'labels.dialogContext.This will reopen the loan account'; + +/** Maximum lengths enforced by the backend transaction validator. */ +const NOTE_MAX_LENGTH = 1000; +const REVERSAL_EXTERNAL_ID_MAX_LENGTH = 100; + +/** + * Builds the `MatDialog` configuration for the reversal dialog. + * @param translateService Translate service used to resolve the labels + * @param titleKey Translation key of the dialog title + * @param confirmButtonKey Translation key of the confirm button + * @param warningKey Translation key of an optional caution shown above the fields + */ +export function buildReversalDialogConfig( + translateService: TranslateService, + titleKey: string, + confirmButtonKey: string, + warningKey?: string +): { data: any; width: string } { + const formfields: FormfieldBase[] = [ + new InputBase({ + controlName: 'note', + label: translateService.instant('labels.inputs.Note'), + value: '', + type: 'text', + required: false, + validators: [Validators.maxLength(NOTE_MAX_LENGTH)], + order: 1 + }), + new InputBase({ + controlName: 'reversalExternalId', + label: translateService.instant('labels.inputs.Reversal External Id'), + value: '', + type: 'text', + required: false, + validators: [Validators.maxLength(REVERSAL_EXTERNAL_ID_MAX_LENGTH)], + order: 2 + }) + ]; + return { + data: { + title: translateService.instant(titleKey), + warning: warningKey ? translateService.instant(warningKey) : undefined, + layout: { addButtonText: confirmButtonKey }, + formfields: formfields, + // The dialog is submittable with both fields left empty. + pristine: false + }, + width: '50rem' + }; +} + +/** + * Copies the filled-in dialog fields onto the request body. Empty values are + * dropped rather than sent as blank strings, because the backend parses every + * parameter present in the body. + * @param payload Request body being assembled + * @param dialogValue Value of the reversal dialog form + */ +export function appendReversalFields(payload: { [key: string]: any }, dialogValue: any): void { + const note = dialogValue?.note?.trim(); + const reversalExternalId = dialogValue?.reversalExternalId?.trim(); + if (note) { + payload.note = note; + } + if (reversalExternalId) { + payload.reversalExternalId = reversalExternalId; + } +} diff --git a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html index 74661fbfd7..ddaa333526 100644 --- a/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html +++ b/src/app/loans/loans-view/transactions-tab/transactions-tab.component.html @@ -197,11 +197,23 @@ {{ 'tooltips.Create Interest Refund' | translate }} } + @if (allowAdjustTransaction(transaction)) { + + } @if (allowUndoTransaction(transaction)) { + } + @if (showTransaction(transaction)) { diff --git a/src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.spec.ts b/src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.spec.ts new file mode 100644 index 0000000000..9c5ae06ef6 --- /dev/null +++ b/src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.spec.ts @@ -0,0 +1,247 @@ +/** + * 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 { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { AlertService } from 'app/core/alert/alert.service'; +import { Dates } from 'app/core/utils/dates'; +import { LoansService } from 'app/loans/loans.service'; +import { OrganizationService } from 'app/organization/organization.service'; +import { LoanProductService } from 'app/products/loan-products/services/loan-product.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { ViewTransactionComponent } from './view-transaction.component'; + +describe('ViewTransactionComponent', () => { + let loansServiceStub: any; + let dialogStub: any; + let routerStub: any; + let dialogResult: any; + + /** + * Builds the resolved transaction with only the fields the actions read. + * @param typeId Loan transaction type id + * @param overrides Extra fields, e.g. `manuallyReversed` or relations + */ + function transaction(typeId: number, overrides: any = {}): any { + return { + id: 77, + amount: 150, + date: [ + 2026, + 6, + 1 + ], + type: { id: typeId, code: 'loanTransactionType.any', value: 'Any' }, + manuallyReversed: false, + transactionRelations: [], + ...overrides + }; + } + + /** + * Builds the component against stubbed collaborators. The gates run in the + * constructor, so they are already resolved when it returns. + * @param transactionData The resolved transaction + * @param options Loan status code and product flavour + */ + function createComponent( + transactionData: any = transaction(2), + options: { loanStatusCode?: string; isWorkingCapital?: boolean } = {} + ): ViewTransactionComponent { + const { loanStatusCode = 'loanStatusType.active', isWorkingCapital = false } = options; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + data: of({ loansAccountTransaction: transactionData }), + parent: { data: of({ loanDetailsAssociationData: { status: { code: loanStatusCode } } }) }, + snapshot: { params: { loanId: '1', clientId: '5' } } + } + }, + { provide: Router, useValue: routerStub }, + { provide: LoansService, useValue: loansServiceStub }, + { provide: MatDialog, useValue: dialogStub }, + { provide: TranslateService, useValue: { instant: (key: string) => key } }, + { provide: OrganizationService, useValue: { getPaymentTypesWithCode: () => of([]) } }, + { provide: AlertService, useValue: { alert: jest.fn() } }, + { + provide: LoanProductService, + useValue: { + isLoanProduct: !isWorkingCapital, + isWorkingCapital, + productType: { value: isWorkingCapital ? 'workingCapital' : 'loan' } + } + }, + { + provide: SettingsService, + useValue: { businessDate: new Date(2026, 5, 10), dateFormat: 'dd MMMM yyyy', language: { code: 'en' } } + }, + { provide: Dates, useValue: { formatDate: () => '01 June 2026' } } + ] + }); + + return TestBed.runInInjectionContext(() => new ViewTransactionComponent()); + } + + beforeEach(() => { + dialogResult = { data: { value: { note: ' Wrong amount ', reversalExternalId: '' } } }; + loansServiceStub = { + executeLoansAccountTransactionsCommand: jest.fn().mockReturnValue(of({ resourceId: 77 })) + }; + dialogStub = { open: jest.fn().mockReturnValue({ afterClosed: () => of(dialogResult) }) }; + routerStub = { navigate: jest.fn() }; + }); + + describe('action availability', () => { + it('offers both actions on a repayment', () => { + const component = createComponent(transaction(2)); + + expect(component.allowUndo).toBe(true); + expect(component.allowEdition).toBe(true); + }); + + it('offers the adjustment on types the previous chargeback rule hid', () => { + // Waive interest, charge adjustment and accrual clear the backend gate but + // were never offered, because edition was tied to the chargeback rule. + [ + 4, + 26, + 10 + ].forEach((typeId) => { + expect(createComponent(transaction(typeId)).allowEdition).toBe(true); + }); + }); + + it('offers only the reversal on a reverse-only type', () => { + const component = createComponent(transaction(21)); + + expect(component.allowUndo).toBe(true); + expect(component.allowEdition).toBe(false); + }); + + it('offers no action on an interest refund', () => { + const component = createComponent(transaction(33)); + + expect(component.allowUndo).toBe(false); + expect(component.allowEdition).toBe(false); + }); + + it('offers no action on a disbursement', () => { + const component = createComponent(transaction(1, { type: { id: 1, disbursement: true } })); + + expect(component.allowUndo).toBe(false); + expect(component.allowEdition).toBe(false); + }); + + it('offers no action on a transaction linked to a chargeback', () => { + const component = createComponent( + transaction(2, { transactionRelations: [{ relationType: 'CHARGEBACK', amount: 10 }] }) + ); + + expect(component.allowUndo).toBe(false); + expect(component.allowEdition).toBe(false); + }); + + it('never offers the adjustment on Working Capital', () => { + const component = createComponent( + transaction(2, { transactionDate: [ + 2026, + 6, + 1 + ] }), + { + isWorkingCapital: true + } + ); + + expect(component.allowEdition).toBe(false); + }); + }); + + describe('reversal', () => { + it('posts the adjust command with a zero amount and the trimmed note', () => { + const component = createComponent(transaction(2)); + + component.undoTransaction(); + + const [ + loanId, + command, + payload, + transactionId + ] = loansServiceStub.executeLoansAccountTransactionsCommand.mock.calls[0]; + expect(loanId).toBe('1'); + expect(command).toBe('adjust'); + expect(transactionId).toBe(77); + expect(payload.transactionAmount).toBe(0); + expect(payload.note).toBe('Wrong amount'); + expect(payload.reversalExternalId).toBeUndefined(); + }); + + it('undoes a Term Loan charge-off on the loan instead of the transaction', () => { + dialogResult = { confirm: true }; + const component = createComponent(transaction(27, { type: { id: 27, chargeoff: true } })); + + component.undoTransaction(); + + const [ + , + command, + , + transactionId + ] = loansServiceStub.executeLoansAccountTransactionsCommand.mock.calls[0]; + expect(command).toBe('undo-charge-off'); + expect(transactionId).toBeUndefined(); + }); + + it('gates the button with the permission of the command it posts', () => { + // The charge-off is undone with its own command, so requiring ADJUST_LOAN + // would hide the action from the users the backend does authorise. + expect(createComponent(transaction(27, { type: { id: 27, chargeoff: true } })).undoPermission).toBe( + 'UNDOCHARGEOFF_LOAN' + ); + expect( + createComponent(transaction(27, { type: { id: 27, chargeoff: true } }), { isWorkingCapital: true }) + .undoPermission + ).toBe('UNDOCHARGEOFF_WORKINGCAPITALLOAN'); + expect(createComponent(transaction(2)).undoPermission).toBe('ADJUST_LOAN'); + }); + + it('names the button after the command that performs the action', () => { + expect(createComponent(transaction(2)).undoButtonLabelKey).toBe('labels.buttons.Reverse'); + expect(createComponent(transaction(27, { type: { id: 27, chargeoff: true } })).undoButtonLabelKey).toBe( + 'labels.buttons.Undo' + ); + }); + }); + + describe('reopening warning', () => { + it('warns in the dialog when the loan is closed or overpaid', () => { + const component = createComponent(transaction(2), { loanStatusCode: 'loanStatusType.overpaid' }); + + expect(component.willReopenLoan).toBe(true); + component.undoTransaction(); + expect(dialogStub.open.mock.calls[0][1].data.warning).toBe( + 'labels.dialogContext.This will reopen the loan account' + ); + }); + + it('shows no warning while the loan is still open', () => { + const component = createComponent(transaction(2)); + + expect(component.willReopenLoan).toBe(false); + component.undoTransaction(); + expect(dialogStub.open.mock.calls[0][1].data.warning).toBeUndefined(); + }); + }); +}); diff --git a/src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.ts b/src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.ts index 17c2943c4e..28aa61076b 100644 --- a/src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.ts +++ b/src/app/loans/loans-view/transactions/view-transaction/view-transaction.component.ts @@ -9,7 +9,7 @@ /** Angular Imports */ import { ChangeDetectionStrategy, Component, OnInit, inject, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; /** Custom Services */ import { LoansService } from 'app/loans/loans.service'; @@ -51,9 +51,22 @@ import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { LoanAccountActionsBaseComponent } from '../../loan-account-actions/loan-account-actions-base.component'; import { isAccrualKindTransaction, isDiscountFeeKindTransaction } from '../../loan-transaction-type.helper'; +import { + adjustmentReopensLoan, + canAdjustLoanTransaction, + canReverseLoanTransaction +} from '../../loan-transaction-adjust.helper'; +import { + appendReversalFields, + buildReversalDialogConfig, + REOPEN_LOAN_WARNING_KEY +} from '../../loan-transaction-reversal.helper'; /** Custom Dialogs */ +/** Permission guarding the Undo button when the transaction is reversed through the adjust command. */ +const DEFAULT_UNDO_PERMISSION = 'ADJUST_LOAN'; + /** * View Transaction Component. * TODO: Add support for account transfers. @@ -97,7 +110,7 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im /** Transaction data. */ transactionData: any; transactionType: LoanTransactionType | null = null; - /** Is Editable */ + /** True when the transaction can be re-submitted with a new date, amount and payment details. */ allowEdition = true; /** Is Undoable */ allowUndo = true; @@ -105,8 +118,12 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im allowChargeback = true; /** True when this is a Working Capital charge-off, which is undone with its own command. */ isWorkingCapitalChargeOff = false; - /** Permission required by the Undo button; Working Capital charge-off has its own. */ - undoPermission = 'ADJUST_LOAN'; + /** True when this is a Term Loan charge-off, which is undone on the loan, not on the transaction. */ + isTermLoanChargeOff = false; + /** True when the loan is closed or overpaid, so acting on the transaction reopens it. */ + willReopenLoan = false; + /** Permission required by the Undo button; each charge-off flavour has its own. */ + undoPermission: string = DEFAULT_UNDO_PERMISSION; existTransactionRelations = false; paymentTypeOptions: {}[] = []; @@ -134,6 +151,11 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im */ constructor() { super(); + this.route.parent?.data + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((data: { loanDetailsAssociationData?: any }) => { + this.willReopenLoan = adjustmentReopensLoan(data.loanDetailsAssociationData?.status); + }); this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data: { loansAccountTransaction: any }) => { this.transactionData = data.loansAccountTransaction; if (this.loanProductService.isWorkingCapital) { @@ -146,17 +168,26 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im this.allowChargeback = false; return; } - this.allowEdition = - !this.transactionData.manuallyReversed && !this.allowTransactionEdition(this.transactionData.type.id); - this.allowUndo = this.allowUndoTransaction( - this.transactionData.manuallyReversed || this.transactionData.reversed, - this.transactionType, - !!this.transactionData.wcLoanId - ); + const alreadyReversed = this.transactionData.manuallyReversed || this.transactionData.reversed; this.isWorkingCapitalChargeOff = this.isWorkingCapital && this.isChargeOff(this.transactionType); + this.isTermLoanChargeOff = !this.isWorkingCapital && this.isChargeOff(this.transactionType); + // A charge-off is undone through its dedicated command rather than the + // adjust command, so the button is gated with the same permission the + // account header action uses instead of the default ADJUST_LOAN. if (this.isWorkingCapitalChargeOff) { this.undoPermission = 'UNDOCHARGEOFF_WORKINGCAPITALLOAN'; + } else if (this.isTermLoanChargeOff) { + this.undoPermission = 'UNDOCHARGEOFF_LOAN'; + } else { + this.undoPermission = DEFAULT_UNDO_PERMISSION; } + // Working Capital keeps its own reversal rules; the gates below describe + // the Term Loan adjust command, which Working Capital does not use. + this.allowEdition = !this.isWorkingCapital && canAdjustLoanTransaction(this.transactionType, alreadyReversed); + this.allowUndo = this.isWorkingCapital + ? this.allowUndoTransaction(alreadyReversed, this.transactionType, !!this.transactionData.wcLoanId) + : canReverseLoanTransaction(this.transactionType, alreadyReversed) || + (!alreadyReversed && this.hasDedicatedUndoCommand(this.transactionType)); this.allowChargeback = this.allowChargebackTransaction(this.transactionType) && !this.transactionData.manuallyReversed; let transactionsChargebackRelated = false; @@ -174,18 +205,17 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im this.isFullRelated = this.amountRelationsAllowed === 0; this.allowChargeback = this.allowChargebackTransaction(this.transactionType) && !this.isFullRelated; } - if (!this.allowChargeback) { - this.allowEdition = false; - } + // A transaction linked to a chargeback is rejected by the backend in both + // modes; re-age and re-amortize are undone from the account header. if ( (this.existTransactionRelations && transactionsChargebackRelated) || this.transactionType.reAge || this.transactionType.reAmortize ) { this.allowUndo = false; + this.allowEdition = false; } if (this.isWorkingCapital) { - this.allowEdition = false; this.allowChargeback = false; } }); @@ -205,18 +235,30 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im } /** - * Allow edit, undo and chargeback actions + * Types that are not reversed through the generic adjust command but have + * their own undo command wired in `undoTransaction()`. + * @param transactionType Transaction type */ - allowTransactionEdition(transactionType: number): boolean { - return ( - transactionType === 20 || - transactionType === 21 || - transactionType === 22 || - transactionType === 23 || - transactionType === 28 + private hasDedicatedUndoCommand(transactionType: LoanTransactionType): boolean { + // The flags are absent from some payloads, so the result is coerced rather + // than leaking `undefined` into the button state. + return !!( + this.isWriteOff(transactionType) || + this.isChargeOff(transactionType) || + transactionType.contractTermination ); } + /** + * The adjust command reverses the transaction; the dedicated commands undo a + * loan level action, so the button names them differently. + */ + get undoButtonLabelKey(): string { + return !this.transactionType || this.isWorkingCapital || this.hasDedicatedUndoCommand(this.transactionType) + ? 'labels.buttons.Undo' + : 'labels.buttons.Reverse'; + } + allowChargebackTransaction(transactionType: LoanTransactionType): boolean { return ( transactionType.repayment || @@ -258,50 +300,28 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im const accountId = this.route.snapshot.params['loanId']; if (this.transactionType.contractTermination) { - const formfields: FormfieldBase[] = [ - new InputBase({ - controlName: 'note', - label: 'Note', - value: '', - type: 'text', - required: false, - order: 1 - }), - new InputBase({ - controlName: 'reversalExternalId', - label: 'externalId', - value: '', - type: 'text', - required: false, - order: 2 - }) - ]; - const data = { - title: this.translateService.instant('labels.heading.Undo Transaction'), - layout: { addButtonText: 'Undo' }, - formfields: formfields, - pristine: false - }; - const undoTransactionAccountDialogRef = this.dialog.open(FormDialogComponent, { data, width: '50rem' }); - undoTransactionAccountDialogRef.afterClosed().subscribe((response: any) => { - if (response?.data) { + this.openReversalDialog('labels.heading.Undo Transaction', 'labels.buttons.Undo') + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((response: any) => { + if (!response?.data) { + return; + } const payload = { note: response.data.value.note, reversalExternalId: response.data.value.reversalExternalId }; - this.loansService.loanActionButtons(accountId, 'undoContractTermination', payload).subscribe(() => { - this.router.navigate(['../'], { - queryParams: { - productType: this.loanProductService.productType.value - }, - relativeTo: this.route - }); - }); - } - }); + this.loansService + .loanActionButtons(accountId, 'undoContractTermination', payload) + .subscribe(() => this.navigateToTransactionList()); + }); } else if (this.isWorkingCapitalChargeOff) { this.undoWorkingCapitalChargeOff(accountId); + } else if (this.isTermLoanChargeOff) { + this.undoTermLoanChargeOff(accountId); + } else if (this.isLoanProduct && !this.isWriteOff(this.transactionType)) { + this.reverseTermLoanTransaction(accountId); } else { const undoTransactionAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, { data: { @@ -332,19 +352,100 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im const undoRequest = this.loanProductService.isWorkingCapital ? this.loansService.applyWorkingCapitalLoanActionCommand(accountId, data, command, transactionId) : this.loansService.executeLoansAccountTransactionsCommand(accountId, command, data, transactionId); - undoRequest.subscribe(() => { - this.router.navigate(['../'], { - queryParams: { - productType: this.loanProductService.productType.value - }, - relativeTo: this.route - }); - }); + undoRequest.subscribe(() => this.navigateToTransactionList()); } }); } } + /** + * Opens the dialog that collects the optional note and reversal external id + * the backend stamps on the reversed transaction. + * @param titleKey Translation key of the dialog title + * @param confirmButtonKey Translation key of the confirm button + */ + private openReversalDialog(titleKey: string, confirmButtonKey: string): MatDialogRef { + return this.dialog.open( + FormDialogComponent, + buildReversalDialogConfig( + this.translateService, + titleKey, + confirmButtonKey, + this.willReopenLoan ? REOPEN_LOAN_WARNING_KEY : undefined + ) + ); + } + + /** + * Reverses a Term Loan transaction through the adjust command. A zero amount + * means reverse only: the original transaction is reversed and no replacement + * is created. + * @param accountId Loan id + */ + private reverseTermLoanTransaction(accountId: string): void { + this.openReversalDialog('labels.heading.Reverse Transaction', 'labels.buttons.Reverse') + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((response: any) => { + if (!response?.data) { + return; + } + const dateFormat = this.settingsService.dateFormat; + const payload: any = { + transactionDate: this.dateUtils.formatDate( + this.transactionData.date && new Date(this.transactionData.date), + dateFormat + ), + transactionAmount: 0, + dateFormat, + locale: this.settingsService.language.code + }; + appendReversalFields(payload, response.data.value); + this.loansService + .executeLoansAccountTransactionsCommand(accountId, 'adjust', payload, this.transactionData.id) + .subscribe(() => this.navigateToTransactionList()); + }); + } + + /** + * Undoes a Term Loan charge-off. The command targets the loan, not a single + * transaction, so it mirrors the action available on the account header + * rather than going through the adjust command. + * @param accountId Loan id + */ + private undoTermLoanChargeOff(accountId: string): void { + this.dialog + .open(ConfirmationDialogComponent, { + data: { + heading: this.translateService.instant('labels.heading.Undo Transaction'), + dialogContext: + this.translateService.instant('labels.dialogContext.Are you sure you want undo the transaction type') + + ' ' + + this.translateService.instant('labels.menus.Charge-Off') + } + }) + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((response: { confirm: boolean }) => { + if (!response?.confirm) { + return; + } + this.loansService + .executeLoansAccountTransactionsCommand(accountId, 'undo-charge-off', {}) + .subscribe(() => this.navigateToTransactionList()); + }); + } + + /** Returns to the transaction list so every resolver refetches the rewritten loan data. */ + private navigateToTransactionList(): void { + this.router.navigate(['../'], { + queryParams: { + productType: this.loanProductService.productType.value + }, + relativeTo: this.route + }); + } + /** * Undoes a Working Capital charge-off from the transaction detail view. * Uses the same dialog and command as the account header action so every @@ -364,14 +465,9 @@ export class ViewTransactionComponent extends LoanAccountActionsBaseComponent im } const payload = buildWorkingCapitalUndoChargeOffPayload(result, this.settingsService.language.code); // The undo charge-off command targets the loan, not a single transaction. - this.loansService.applyWorkingCapitalLoanActionCommand(accountId, payload, 'undoChargeOff').subscribe(() => { - this.router.navigate(['../'], { - queryParams: { - productType: this.loanProductService.productType.value - }, - relativeTo: this.route - }); - }); + this.loansService + .applyWorkingCapitalLoanActionCommand(accountId, payload, 'undoChargeOff') + .subscribe(() => this.navigateToTransactionList()); }); } diff --git a/src/app/shared/form-dialog/form-dialog.component.html b/src/app/shared/form-dialog/form-dialog.component.html index 9790a60325..a0f9585b16 100644 --- a/src/app/shared/form-dialog/form-dialog.component.html +++ b/src/app/shared/form-dialog/form-dialog.component.html @@ -8,6 +8,10 @@

{{ data.title }}

+@if (data.warning) { +

{{ data.warning }}

+} +