Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/app/core/http/error-handler.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})
);
});
});
});
47 changes: 33 additions & 14 deletions src/app/core/http/error-handler.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>): Observable<HttpEvent<any>> {
// Tenant branding is cosmetic and optional: the endpoint is absent on
// deployments without the self-service plugin. Let the caller fall back to
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/app/loans/loans-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ const routes: Routes = [
{
path: 'edit',
component: EditTransactionComponent,
data: { breadcrumb: 'Edit', routeParamBreadcrumb: false },
data: { breadcrumb: 'Adjust', routeParamBreadcrumb: false },
resolve: {
loansAccountTransactionTemplate: LoansAccountTransactionTemplateResolver
}
Expand Down
219 changes: 219 additions & 0 deletions src/app/loans/loans-view/loan-transaction-adjust.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading